entune 0.1.0__py3-none-any.whl

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.
Files changed (133) hide show
  1. entune/__init__.py +7 -0
  2. entune/__main__.py +3 -0
  3. entune/api/__init__.py +0 -0
  4. entune/api/common.py +34 -0
  5. entune/api/data.py +125 -0
  6. entune/api/desktop.py +65 -0
  7. entune/api/dictionary.py +93 -0
  8. entune/api/learning.py +179 -0
  9. entune/api/models.py +47 -0
  10. entune/api/recordings.py +130 -0
  11. entune/api/settings.py +252 -0
  12. entune/app/__init__.py +0 -0
  13. entune/app/audio_import.py +183 -0
  14. entune/app/capture.py +61 -0
  15. entune/app/decision_models.py +35 -0
  16. entune/app/desktop_bridge.py +40 -0
  17. entune/app/dictation.py +402 -0
  18. entune/app/dictionary_file.py +101 -0
  19. entune/app/entune.py +123 -0
  20. entune/app/learning.py +212 -0
  21. entune/app/local_data.py +109 -0
  22. entune/app/metrics.py +170 -0
  23. entune/app/models.py +136 -0
  24. entune/app/operations.py +125 -0
  25. entune/app/settings.py +282 -0
  26. entune/app/shortcuts.py +112 -0
  27. entune/app/suggestion_runs.py +421 -0
  28. entune/assets/Entune.icns +0 -0
  29. entune/assets/icon-512.png +0 -0
  30. entune/assets/icon.png +0 -0
  31. entune/assets/menubar-template.png +0 -0
  32. entune/audio/__init__.py +0 -0
  33. entune/audio/convert.py +24 -0
  34. entune/audio/formats.py +166 -0
  35. entune/audio/recorder.py +132 -0
  36. entune/cli.py +233 -0
  37. entune/desktop/__init__.py +0 -0
  38. entune/desktop/app.py +545 -0
  39. entune/desktop/engine.py +105 -0
  40. entune/desktop/macos/__init__.py +0 -0
  41. entune/desktop/macos/actions.py +174 -0
  42. entune/desktop/macos/adapters.py +49 -0
  43. entune/desktop/macos/bundle.py +205 -0
  44. entune/desktop/macos/hotkeys.py +244 -0
  45. entune/desktop/macos/indicator.py +119 -0
  46. entune/desktop/macos/permissions.py +61 -0
  47. entune/desktop/macos/webview.py +172 -0
  48. entune/desktop/platform.py +117 -0
  49. entune/desktop/webview/__init__.py +0 -0
  50. entune/desktop/webview/shell.py +348 -0
  51. entune/dictionary/__init__.py +0 -0
  52. entune/dictionary/changes.py +167 -0
  53. entune/dictionary/corrections.py +110 -0
  54. entune/dictionary/document.py +207 -0
  55. entune/dictionary/entries.py +161 -0
  56. entune/dictionary/matching.py +201 -0
  57. entune/learning/__init__.py +0 -0
  58. entune/learning/batches.py +185 -0
  59. entune/learning/generate.py +175 -0
  60. entune/learning/inputs.py +33 -0
  61. entune/learning/replies.py +331 -0
  62. entune/learning/suggestion_model/__init__.py +30 -0
  63. entune/learning/suggestion_model/call.py +179 -0
  64. entune/learning/suggestion_model/catalog.py +75 -0
  65. entune/learning/suggestion_model/chatgpt.py +175 -0
  66. entune/learning/suggestion_model/providers.py +148 -0
  67. entune/learning/suggestion_model/request.py +60 -0
  68. entune/processing/__init__.py +0 -0
  69. entune/processing/cleanup.py +44 -0
  70. entune/processing/formatting.py +83 -0
  71. entune/processing/jev.py +254 -0
  72. entune/processing/jev_client.py +354 -0
  73. entune/processing/laya.py +238 -0
  74. entune/processing/pipeline.py +164 -0
  75. entune/processing/results.py +120 -0
  76. entune/processing/text_edits.py +47 -0
  77. entune/prompts/__init__.py +50 -0
  78. entune/prompts/dictionary-foundation.txt +87 -0
  79. entune/prompts/dictionary-generate-system.txt +70 -0
  80. entune/prompts/dictionary-generate-user.txt +7 -0
  81. entune/prompts/dictionary-refine-system.txt +152 -0
  82. entune/prompts/dictionary-refine-user.txt +7 -0
  83. entune/prompts/jev-cleanup.json +18 -0
  84. entune/prompts/jev-formatting.json +30 -0
  85. entune/prompts/jev-meaning-examples.json +8 -0
  86. entune/prompts/jev-meaning-option.txt +1 -0
  87. entune/prompts/jev-meaning-reading.txt +1 -0
  88. entune/prompts/jev-meaning.json +8 -0
  89. entune/providers/__init__.py +1 -0
  90. entune/providers/cloud/__init__.py +1 -0
  91. entune/providers/cloud/assemblyai.py +245 -0
  92. entune/providers/cloud/contracts.py +34 -0
  93. entune/providers/cloud/elevenlabs.py +45 -0
  94. entune/providers/cloud/groq.py +45 -0
  95. entune/providers/cloud/http.py +58 -0
  96. entune/providers/cloud/soniox.py +118 -0
  97. entune/providers/cloud/xai.py +45 -0
  98. entune/providers/contracts.py +61 -0
  99. entune/providers/local/__init__.py +1 -0
  100. entune/providers/local/contracts.py +36 -0
  101. entune/providers/local/downloads.py +107 -0
  102. entune/providers/local/parakeet.py +256 -0
  103. entune/providers/local/parakeet_helper.py +119 -0
  104. entune/providers/local/whisper.py +262 -0
  105. entune/providers/registry.py +57 -0
  106. entune/providers/resources.py +169 -0
  107. entune/py.typed +0 -0
  108. entune/server.py +114 -0
  109. entune/storage/__init__.py +0 -0
  110. entune/storage/data_folder.py +52 -0
  111. entune/storage/paths.py +34 -0
  112. entune/storage/records.py +93 -0
  113. entune/storage/schema.py +66 -0
  114. entune/storage/store.py +529 -0
  115. entune/web/app.js +367 -0
  116. entune/web/audio-onboarding.js +265 -0
  117. entune/web/dictionary-build.js +141 -0
  118. entune/web/dictionary-view.js +1215 -0
  119. entune/web/favicon.svg +1 -0
  120. entune/web/history-card.js +316 -0
  121. entune/web/history.js +96 -0
  122. entune/web/index.html +650 -0
  123. entune/web/permissions-view.js +79 -0
  124. entune/web/recording.js +161 -0
  125. entune/web/settings-view.js +686 -0
  126. entune/web/style.css +638 -0
  127. entune/web/tokens.css +165 -0
  128. entune/web/ui.js +83 -0
  129. entune-0.1.0.dist-info/METADATA +230 -0
  130. entune-0.1.0.dist-info/RECORD +133 -0
  131. entune-0.1.0.dist-info/WHEEL +4 -0
  132. entune-0.1.0.dist-info/entry_points.txt +3 -0
  133. entune-0.1.0.dist-info/licenses/LICENSE +21 -0
entune/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Entune: a personal dictation workbench.
2
+
3
+ Record a clip, transcribe it with the speech-to-text model you pick, keep the
4
+ history, retry with another model when one fails, copy the result.
5
+ """
6
+
7
+ __version__ = "0.1.0"
entune/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from entune.cli import main
2
+
3
+ main()
entune/api/__init__.py ADDED
File without changes
entune/api/common.py ADDED
@@ -0,0 +1,34 @@
1
+ """What the API modules share: error responses and the JSON forms of records."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import asdict
6
+ from typing import Any
7
+
8
+ from starlette.responses import PlainTextResponse, Response
9
+
10
+ from entune.app.entune import Entune
11
+ from entune.storage.records import Recording
12
+
13
+
14
+ def bad(message: str, status: int = 400) -> Response:
15
+ return PlainTextResponse(message, status_code=status)
16
+
17
+
18
+ def recording_json(recording: Recording) -> dict[str, Any]:
19
+ return asdict(recording)
20
+
21
+
22
+ def shortcuts_json(app: Entune) -> dict[str, str | None]:
23
+ shortcuts = app.settings.shortcuts()
24
+ return {
25
+ "hold": "+".join(shortcuts.hold) if shortcuts.hold else None,
26
+ "toggle": "+".join(shortcuts.toggle) if shortcuts.toggle else None,
27
+ "cancel": "+".join(shortcuts.cancel) if shortcuts.cancel else None,
28
+ }
29
+
30
+
31
+ def optional_text(value: object, name: str) -> str | None:
32
+ if value is not None and not isinstance(value, str):
33
+ raise ValueError(f"{name} must be a string or null")
34
+ return value
entune/api/data.py ADDED
@@ -0,0 +1,125 @@
1
+ """Local data: what is stored, exports, and deleting everything."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import asdict
7
+ from pathlib import Path
8
+ from tempfile import TemporaryDirectory
9
+ from typing import Any
10
+ from zipfile import ZipFile
11
+
12
+ from starlette.background import BackgroundTask
13
+ from starlette.concurrency import run_in_threadpool
14
+ from starlette.requests import Request
15
+ from starlette.responses import FileResponse, JSONResponse, Response
16
+ from starlette.routing import Route
17
+
18
+ from entune.api.common import bad, recording_json
19
+ from entune.app.entune import Entune
20
+ from entune.app.operations import Busy
21
+ from entune.app.suggestion_runs import JobConflict
22
+
23
+ RESET_PHRASE = "delete everything"
24
+
25
+
26
+ def routes(app: Entune) -> list[Route]:
27
+ # Deleting all local data: the page lists the scope first, then sends the phrase
28
+ # the person typed; the server checks it too.
29
+ def data_inventory(_: Request) -> Response:
30
+ return JSONResponse(app.data.data_inventory())
31
+
32
+ async def reset_data(request: Request) -> Response:
33
+ try:
34
+ body = await request.json()
35
+ except ValueError:
36
+ body = None
37
+ if not isinstance(body, dict) or body.get("confirm") != RESET_PHRASE:
38
+ return bad(f'Type "{RESET_PHRASE}" to confirm', 400)
39
+ try:
40
+ result = await run_in_threadpool(app.data.reset_data)
41
+ except (JobConflict, Busy) as exc:
42
+ return bad(str(exc), 409)
43
+ except ValueError as exc:
44
+ return bad(str(exc), 409)
45
+ except OSError as exc:
46
+ return bad(
47
+ f"Could not delete everything: {exc}. What was already deleted is gone;"
48
+ " the rest is still there.",
49
+ 500,
50
+ )
51
+ return JSONResponse(result)
52
+
53
+ def export_transcripts(_: Request) -> Response:
54
+ try:
55
+ with app.data.using_data("export"):
56
+ recordings = app.store.list_recordings()
57
+ except Busy as exc:
58
+ return bad(str(exc), 409)
59
+ return JSONResponse(
60
+ {"recordings": [recording_json(r) for r in recordings]},
61
+ headers={
62
+ "Content-Disposition": 'attachment; filename="entune-transcripts.json"',
63
+ "Cache-Control": "no-store",
64
+ "X-Content-Type-Options": "nosniff",
65
+ },
66
+ )
67
+
68
+ def export_audio(_: Request) -> Response:
69
+ # Build on disk, off the event loop: years of audio must not fill memory.
70
+ # FileResponse streams it; the temporary copy is removed after download.
71
+ directory = TemporaryDirectory(prefix="entune-export-")
72
+ path = Path(directory.name) / "entune-audio.zip"
73
+ try:
74
+ # A data reset waits until the archive is built; afterwards it has its own copy.
75
+ with (
76
+ app.data.using_data("export"),
77
+ ZipFile(path, "w", strict_timestamps=False) as archive,
78
+ ):
79
+ recordings = app.store.list_recordings()
80
+ imported = app.store.dictionary_audio()
81
+ manifest: dict[str, list[dict[str, Any]]] = {
82
+ "recordings": [],
83
+ "dictionary_audio": [],
84
+ }
85
+ for recording in recordings:
86
+ source = app.store.audio_path(recording)
87
+ name = f"audio/{source.name}"
88
+ archive.write(source, name)
89
+ manifest["recordings"].append(
90
+ {
91
+ "id": recording.id,
92
+ "created_at": recording.created_at,
93
+ "file": name,
94
+ "mime": recording.mime,
95
+ }
96
+ )
97
+ for audio in imported:
98
+ source = app.store.dictionary_audio_path(audio)
99
+ name = f"dictionary-audio/{source.name}"
100
+ archive.write(source, name)
101
+ manifest["dictionary_audio"].append({**asdict(audio), "file": name})
102
+ archive.writestr(
103
+ "manifest.json", json.dumps(manifest, ensure_ascii=False, indent=2)
104
+ )
105
+ except Exception as exc:
106
+ directory.cleanup()
107
+ if isinstance(exc, Busy):
108
+ return bad(str(exc), 409)
109
+ if isinstance(exc, OSError):
110
+ return bad(f"Could not export audio: {exc}", 500)
111
+ raise
112
+ return FileResponse(
113
+ path,
114
+ media_type="application/zip",
115
+ filename="entune-audio.zip",
116
+ headers={"Cache-Control": "no-store", "X-Content-Type-Options": "nosniff"},
117
+ background=BackgroundTask(directory.cleanup),
118
+ )
119
+
120
+ return [
121
+ Route("/api/exports/audio", export_audio),
122
+ Route("/api/exports/transcripts", export_transcripts),
123
+ Route("/api/data", data_inventory),
124
+ Route("/api/data/reset", reset_data, methods=["POST"]),
125
+ ]
entune/api/desktop.py ADDED
@@ -0,0 +1,65 @@
1
+ """The menu-bar app: its status, shortcut capture, permission requests, the window."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import asdict
6
+
7
+ from starlette.requests import Request
8
+ from starlette.responses import JSONResponse, Response
9
+ from starlette.routing import Route
10
+
11
+ from entune import __version__
12
+ from entune.api.common import bad, shortcuts_json
13
+ from entune.app.entune import Entune
14
+
15
+
16
+ def routes(app: Entune) -> list[Route]:
17
+ async def start_capture(_: Request) -> Response:
18
+ if not app.capture.can_capture():
19
+ return bad("Recording a shortcut needs the menu-bar app; type the keys instead.", 409)
20
+ return JSONResponse(asdict(app.capture.start_capture()), status_code=202)
21
+
22
+ async def capture_status(_: Request) -> Response:
23
+ return JSONResponse(asdict(app.capture.capture_status()))
24
+
25
+ async def cancel_capture(_: Request) -> Response:
26
+ app.capture.cancel_capture()
27
+ return JSONResponse({"ok": True})
28
+
29
+ def status(_: Request) -> Response:
30
+ return JSONResponse(
31
+ {
32
+ "version": __version__,
33
+ "shortcuts": shortcuts_json(app),
34
+ "defaultModel": app.models.default_model(),
35
+ **app.desktop.desktop_status(),
36
+ }
37
+ )
38
+
39
+ async def show_window(_: Request) -> Response:
40
+ if not app.desktop.show_window():
41
+ return bad("No desktop app is running to show a window", 409)
42
+ return JSONResponse({"ok": True})
43
+
44
+ async def request_permission(request: Request) -> Response:
45
+ name = request.path_params["name"]
46
+ if name not in {"microphone", "inputMonitoring", "accessibility"}:
47
+ return bad("Unknown permission")
48
+ try:
49
+ body = await request.json()
50
+ except ValueError:
51
+ return bad("Body must be JSON")
52
+ if not isinstance(body, dict) or not isinstance(body.get("openSettings", False), bool):
53
+ return bad("openSettings must be a boolean")
54
+ if not app.desktop.request_permission(name, body.get("openSettings", False)):
55
+ return bad("Open the Entune desktop app to set up permissions", 409)
56
+ return JSONResponse({"ok": True}, status_code=202)
57
+
58
+ return [
59
+ Route("/api/capture", start_capture, methods=["POST"]),
60
+ Route("/api/capture", capture_status, methods=["GET"]),
61
+ Route("/api/capture", cancel_capture, methods=["DELETE"]),
62
+ Route("/api/status", status),
63
+ Route("/api/window", show_window, methods=["POST"]),
64
+ Route("/api/permissions/{name}", request_permission, methods=["POST"]),
65
+ ]
@@ -0,0 +1,93 @@
1
+ """The dictionary: read and save the file, pin meanings, and corrections from other apps."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import asdict
6
+
7
+ from starlette.concurrency import run_in_threadpool
8
+ from starlette.requests import Request
9
+ from starlette.responses import JSONResponse, PlainTextResponse, Response
10
+ from starlette.routing import Route
11
+
12
+ from entune.api.common import bad
13
+ from entune.app.dictionary_file import DictionaryChanged
14
+ from entune.app.entune import Entune
15
+ from entune.app.operations import Busy
16
+
17
+
18
+ def routes(app: Entune) -> list[Route]:
19
+ def _dictionary_response(app: Entune) -> Response:
20
+ text, version = app.dictionary.dictionary_snapshot()
21
+ return PlainTextResponse(
22
+ text,
23
+ media_type="application/json",
24
+ headers={"ETag": f'"{version}"'},
25
+ )
26
+
27
+ def get_dictionary(_: Request) -> Response:
28
+ try:
29
+ return _dictionary_response(app)
30
+ except ValueError as exc:
31
+ return bad(f"dictionary.json on disk is not usable: {exc}", 500)
32
+
33
+ async def put_dictionary(request: Request) -> Response:
34
+ expected = request.headers.get("if-match")
35
+ try:
36
+ await run_in_threadpool(
37
+ app.dictionary.set_dictionary,
38
+ (await request.body()).decode("utf-8"),
39
+ expected.strip('"') if expected else None,
40
+ )
41
+ except DictionaryChanged as exc:
42
+ return bad(str(exc), 409)
43
+ except ValueError as exc:
44
+ return bad(str(exc), 409 if isinstance(exc, Busy) else 400)
45
+ return await run_in_threadpool(_dictionary_response, app)
46
+
47
+ async def pin_meaning(request: Request) -> Response:
48
+ try:
49
+ body = await request.json()
50
+ version = request.headers.get("if-match")
51
+ if (
52
+ not isinstance(body, dict)
53
+ or set(body) not in ({"model"}, {"model", "group", "meaning"})
54
+ or not all(isinstance(v, str) and v for v in body.values())
55
+ or not version
56
+ ):
57
+ raise ValueError(
58
+ "Pinning needs a model, optional group and meaning IDs, and If-Match"
59
+ )
60
+ await run_in_threadpool(
61
+ app.dictionary.pin_meaning,
62
+ body["model"],
63
+ body.get("group"),
64
+ body.get("meaning"),
65
+ version.strip('"'),
66
+ )
67
+ except DictionaryChanged as exc:
68
+ return bad(str(exc), 409)
69
+ except ValueError as exc:
70
+ return bad(str(exc), 409 if isinstance(exc, Busy) else 400)
71
+ return await run_in_threadpool(_dictionary_response, app)
72
+
73
+ def received_corrections(_: Request) -> Response:
74
+ return JSONResponse([asdict(c) for c in app.store.list_corrections()])
75
+
76
+ async def agent_corrections(request: Request) -> Response:
77
+ try:
78
+ body = await request.json()
79
+ except ValueError:
80
+ return bad("Body must be JSON")
81
+ try:
82
+ added = await run_in_threadpool(app.dictionary.add_agent_corrections, body)
83
+ except ValueError as exc:
84
+ return bad(str(exc), 409 if isinstance(exc, Busy) else 400)
85
+ return JSONResponse({"added": [e.as_json() for e in added]})
86
+
87
+ return [
88
+ Route("/api/dictionary", get_dictionary, methods=["GET"]),
89
+ Route("/api/dictionary/pin", pin_meaning, methods=["POST"]),
90
+ Route("/api/dictionary", put_dictionary, methods=["PUT"]),
91
+ Route("/api/dictionary/corrections", agent_corrections, methods=["POST"]),
92
+ Route("/api/dictionary/corrections", received_corrections, methods=["GET"]),
93
+ ]
entune/api/learning.py ADDED
@@ -0,0 +1,179 @@
1
+ """Dictionary suggestions: runs and their proposals, and audio imported to learn from."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from dataclasses import asdict
7
+
8
+ from starlette.concurrency import run_in_threadpool
9
+ from starlette.datastructures import UploadFile
10
+ from starlette.requests import Request
11
+ from starlette.responses import FileResponse, JSONResponse, Response
12
+ from starlette.routing import Route
13
+
14
+ from entune.api.common import bad
15
+ from entune.app import audio_import
16
+ from entune.app.dictionary_file import DictionaryChanged
17
+ from entune.app.entune import Entune
18
+ from entune.app.operations import Busy
19
+ from entune.app.suggestion_runs import JobConflict
20
+ from entune.audio.formats import extension_for, safe_mime
21
+
22
+
23
+ def routes(app: Entune) -> list[Route]:
24
+ def build_status(request: Request) -> Response:
25
+ try:
26
+ return JSONResponse(
27
+ app.learning.dictionary_build_status(request.path_params.get("job_id"))
28
+ )
29
+ except (JobConflict, Busy) as exc:
30
+ return bad(str(exc), 409)
31
+
32
+ async def start_build(request: Request) -> Response:
33
+ try:
34
+ body = await request.json()
35
+ if (
36
+ not isinstance(body, dict)
37
+ or set(body) - {"source", "mode", "scope", "audio_ids"}
38
+ or body.get("source") not in ("history", "audio")
39
+ ):
40
+ raise ValueError("Choose source history or audio")
41
+ if body.get("mode") not in ("generate", "refine"):
42
+ raise ValueError("Choose mode generate or refine")
43
+ ids = body.get("audio_ids")
44
+ if ids is not None and (
45
+ not isinstance(ids, list) or not all(isinstance(i, str) for i in ids)
46
+ ):
47
+ raise ValueError("audio_ids must list saved recording IDs")
48
+ state = await run_in_threadpool(
49
+ app.learning.start_dictionary_build,
50
+ body["source"],
51
+ mode=body["mode"],
52
+ scope=body.get("scope", "new"),
53
+ audio_ids=ids,
54
+ )
55
+ except (JobConflict, Busy) as exc:
56
+ return bad(str(exc), 409)
57
+ except ValueError as exc:
58
+ return bad(str(exc))
59
+ return JSONResponse(state, status_code=202)
60
+
61
+ async def act_on_build(request: Request) -> Response:
62
+ action = request.path_params.get("action", "discard")
63
+ methods = {
64
+ "cancel": app.learning.cancel_dictionary_build,
65
+ "accept": app.learning.accept_dictionary_build,
66
+ "discard": app.learning.discard_dictionary_build,
67
+ "retry": app.learning.retry_dictionary_build,
68
+ }
69
+ if action not in methods:
70
+ return bad("Unknown dictionary job action", 404)
71
+ try:
72
+ if action == "accept":
73
+ body = await request.json() if await request.body() else {}
74
+ if not isinstance(body, dict) or set(body) - {"selected"}:
75
+ raise ValueError("Apply a list of selected proposals")
76
+ await run_in_threadpool(
77
+ app.learning.accept_dictionary_build,
78
+ request.path_params["job_id"],
79
+ body.get("selected"),
80
+ )
81
+ else:
82
+ await run_in_threadpool(methods[action], request.path_params["job_id"])
83
+ except (JobConflict, DictionaryChanged, Busy) as exc:
84
+ return bad(str(exc), 409)
85
+ except ValueError as exc:
86
+ return bad(str(exc))
87
+ return JSONResponse(app.learning.dictionary_build_status())
88
+
89
+ def dictionary_audio(_: Request) -> Response:
90
+ with app.data.using_data("audio listing"):
91
+ # Which speech models already transcribed each recording, so the page can offer
92
+ # recordings the selected model has not heard. Imported audio has none.
93
+ models = {f"recording:{r}": names for r, names in app.store.recording_models().items()}
94
+ # Sources are chosen separately in the page: this app's recordings, another
95
+ # dictation app's imports, or files imported from a folder.
96
+ items = [
97
+ {
98
+ **asdict(item),
99
+ "models": models.get(item.id, []),
100
+ # Imports made before the source was kept fall back to the Wispr file name.
101
+ "source": "entune"
102
+ if item.id.startswith("recording:")
103
+ else item.source or ("wispr" if item.name.startswith("wispr-") else "folder"),
104
+ }
105
+ for item, _ in app.store.learning_audio()
106
+ ]
107
+ return JSONResponse(
108
+ {
109
+ "apps": [{"id": a.id, "name": a.name, "note": a.note} for a in audio_import.APPS],
110
+ "count": len(items),
111
+ "items": items,
112
+ "seconds": sum(item["seconds"] or 0 for item in items),
113
+ "unknownDurations": sum(item["seconds"] is None for item in items),
114
+ }
115
+ )
116
+
117
+ def imported[T](action: Callable[..., T], *args: object) -> T:
118
+ """An import runs whole or not at all around a data reset."""
119
+ with app.data.using_data("audio import"):
120
+ return action(*args)
121
+
122
+ async def import_dictionary_audio(request: Request) -> Response:
123
+ async with request.form() as form:
124
+ audio = form.get("audio")
125
+ if not isinstance(audio, UploadFile):
126
+ return bad("No audio file in request")
127
+ # The file's modification time, in milliseconds, is the best recording date
128
+ # a folder offers; without it the import is undated.
129
+ modified = form.get("modified")
130
+ try:
131
+ added = await run_in_threadpool(
132
+ imported,
133
+ audio_import.import_audio,
134
+ app.store,
135
+ await audio.read(),
136
+ audio.filename or "audio",
137
+ audio_import.recorded_at(float(modified))
138
+ if isinstance(modified, str)
139
+ else None,
140
+ )
141
+ except (ValueError, OSError) as exc:
142
+ return bad(str(exc))
143
+ return JSONResponse({"added": added})
144
+
145
+ async def import_app(request: Request) -> Response:
146
+ try:
147
+ result = await run_in_threadpool(
148
+ imported, audio_import.import_app, app.store, request.path_params["app"]
149
+ )
150
+ except (ValueError, OSError) as exc:
151
+ return bad(str(exc))
152
+ return JSONResponse(result)
153
+
154
+ def imported_audio(request: Request) -> Response:
155
+ # Resolve only through the saved catalog; the identifier never becomes a path.
156
+ item = next(
157
+ (a for a in app.store.dictionary_audio() if a.id == request.path_params["id"]), None
158
+ )
159
+ if item is None or not (path := app.store.dictionary_audio_path(item)).is_file():
160
+ return bad("No such imported audio", 404)
161
+ return FileResponse(
162
+ path,
163
+ media_type=safe_mime(item.mime),
164
+ filename=f"imported.{extension_for(item.mime)}",
165
+ content_disposition_type="inline",
166
+ headers={"X-Content-Type-Options": "nosniff", "Content-Security-Policy": "sandbox"},
167
+ )
168
+
169
+ return [
170
+ Route("/api/dictionary/build", build_status, methods=["GET"]),
171
+ Route("/api/dictionary/build", start_build, methods=["POST"]),
172
+ Route("/api/dictionary/build/{job_id}", build_status, methods=["GET"]),
173
+ Route("/api/dictionary/build/{job_id}", act_on_build, methods=["DELETE"]),
174
+ Route("/api/dictionary/build/{job_id}/{action}", act_on_build, methods=["POST"]),
175
+ Route("/api/dictionary/audio", dictionary_audio, methods=["GET"]),
176
+ Route("/api/dictionary/audio", import_dictionary_audio, methods=["POST"]),
177
+ Route("/api/dictionary/audio/apps/{app:str}", import_app, methods=["POST"]),
178
+ Route("/api/dictionary/audio/{id:str}/file", imported_audio, methods=["GET"]),
179
+ ]
entune/api/models.py ADDED
@@ -0,0 +1,47 @@
1
+ """Speech models: the model list, local model downloads, and performance."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import asdict
6
+
7
+ from starlette.concurrency import run_in_threadpool
8
+ from starlette.requests import Request
9
+ from starlette.responses import JSONResponse, Response
10
+ from starlette.routing import Route
11
+
12
+ from entune.api.common import bad
13
+ from entune.app.entune import Entune
14
+ from entune.app.metrics import model_metrics
15
+
16
+
17
+ def routes(app: Entune) -> list[Route]:
18
+ def models(_: Request) -> Response:
19
+ return JSONResponse([asdict(m) for m in app.models.available_models()])
20
+
21
+ def local_models(_: Request) -> Response:
22
+ return JSONResponse([asdict(m) for m in app.models.local_models()])
23
+
24
+ async def download_local_model(request: Request) -> Response:
25
+ try:
26
+ await run_in_threadpool(app.models.download_local_model, request.path_params["name"])
27
+ except ValueError as exc:
28
+ return bad(str(exc), 404)
29
+ return JSONResponse({"ok": True})
30
+
31
+ async def remove_local_model(request: Request) -> Response:
32
+ try:
33
+ await run_in_threadpool(app.models.remove_local_model, request.path_params["name"])
34
+ except ValueError as exc:
35
+ return bad(str(exc), 404)
36
+ return JSONResponse({"ok": True})
37
+
38
+ def metrics(_: Request) -> Response:
39
+ return JSONResponse([asdict(m) for m in model_metrics(app.store, app.providers)])
40
+
41
+ return [
42
+ Route("/api/models", models),
43
+ Route("/api/metrics", metrics),
44
+ Route("/api/local/models", local_models),
45
+ Route("/api/local/models/{name}/download", download_local_model, methods=["POST"]),
46
+ Route("/api/local/models/{name}", remove_local_model, methods=["DELETE"]),
47
+ ]