Compare commits
22
Commits
@@ -25,6 +25,12 @@ What you expected to happen.
|
||||
|
||||
If applicable, add screenshots or paste relevant logs from **Settings → Logs**.
|
||||
|
||||
> **Tip:** **Settings → About → "Save diagnostic bundle"** produces a zip
|
||||
> (self-check report, recent errors, scrubbed log tails) you can drag onto
|
||||
> this issue — it answers most environment questions below automatically.
|
||||
> Headless installs: `python backend/main.py --diagnose` prints the same
|
||||
> self-check (`--deep` also test-loads the active engine).
|
||||
|
||||
## Environment
|
||||
|
||||
- **OS:** [e.g. macOS 15.2, Windows 11, Ubuntu 24.04]
|
||||
@@ -32,6 +38,7 @@ If applicable, add screenshots or paste relevant logs from **Settings → Logs**
|
||||
- **Version:** [e.g. v0.2.7 — check Settings → About]
|
||||
- **GPU:** [e.g. NVIDIA RTX 4090 / Apple M3 Pro / CPU only]
|
||||
- **RAM:** [e.g. 16 GB]
|
||||
- **Active TTS engine:** [e.g. omnivoice — check Settings → Engines]
|
||||
|
||||
## Additional context
|
||||
|
||||
|
||||
@@ -410,7 +410,11 @@ jobs:
|
||||
set -euo pipefail
|
||||
CONF=frontend/src-tauri/tauri.conf.json
|
||||
BASE=$(jq -r .version "$CONF")
|
||||
PREVIEW_VERSION="${BASE}-preview.${{ github.run_number }}"
|
||||
# MSI/WiX requires the semver pre-release identifier to be numeric-only
|
||||
# (and <= 65535). "preview.N" hard-fails the Windows bundler, so the
|
||||
# preview stamp is BASE-N — still sorts below the stable BASE for the
|
||||
# updater, still unique per run.
|
||||
PREVIEW_VERSION="${BASE}-${{ github.run_number }}"
|
||||
tmp=$(mktemp)
|
||||
jq --arg v "$PREVIEW_VERSION" '.version = $v' "$CONF" > "$tmp"
|
||||
mv "$tmp" "$CONF"
|
||||
@@ -540,7 +544,13 @@ jobs:
|
||||
|
||||
# Gather artifact paths per matrix leg's `bundles` (msi/app/dmg/deb/appimage/updater).
|
||||
# `find` is portable across all three runners (Git Bash on Windows).
|
||||
mapfile -t ARTIFACTS < <(find "$BUNDLE_DIR" -type f \
|
||||
# NB: macOS runners use /bin/bash 3.2, which has no `mapfile` (a bash 4+
|
||||
# builtin) — using it 127'd this step and dropped the macOS SHA256SUMS
|
||||
# for v0.3.1 and v0.3.2. A `while read` loop is portable to bash 3.2.
|
||||
ARTIFACTS=()
|
||||
while IFS= read -r artifact; do
|
||||
ARTIFACTS+=("$artifact")
|
||||
done < <(find "$BUNDLE_DIR" -type f \
|
||||
\( -name "*.dmg" -o -name "*.app.tar.gz" -o -name "*.app.tar.gz.sig" \
|
||||
-o -name "*.msi" -o -name "*.msi.sig" \
|
||||
-o -name "*.AppImage" -o -name "*.AppImage.sig" \
|
||||
|
||||
@@ -6,6 +6,56 @@ The format is loosely based on [Keep a Changelog](https://keepachangelog.com/).
|
||||
Versions track the desktop app (`tauri.conf.json` + `frontend/src-tauri/Cargo.toml`).
|
||||
The bundled TTS model package (`pyproject.toml`) is versioned independently.
|
||||
|
||||
## [0.3.5] — 2026-06-03
|
||||
|
||||
### Fixed
|
||||
- **Speaker diarization failed on PyTorch ≥ 2.6** (`Weights only load failed …
|
||||
Unsupported global: torch.torch_version.TorchVersion`) even with the pyannote
|
||||
license accepted. PyTorch 2.6 made `torch.load` default to
|
||||
`weights_only=True`, whose secure unpickler rejects the pyannote checkpoint's
|
||||
metadata globals. The diarization loader now registers the same safe-globals
|
||||
allowlist the WhisperX VAD load already uses, so the secure load succeeds.
|
||||
(#270)
|
||||
|
||||
## [0.3.4] — 2026-06-03
|
||||
|
||||
### Fixed
|
||||
- **Transcription on Windows + NVIDIA failed with `Could not locate
|
||||
cudnn_ops_infer64_8.dll`.** WhisperX/faster-whisper need cuDNN 8 (via
|
||||
CTranslate2); when the side-loaded `cudnn8_compat` libs are missing, the
|
||||
**PyTorch Whisper** backend (Settings → Models) now works as a drop-in
|
||||
fallback — it builds its own transformers pipeline on PyTorch's cuDNN-9
|
||||
stack, with no CTranslate2/cuDNN-8 dependency and no
|
||||
`OMNIVOICE_PRELOAD_TTS_ASR=1` required. (#255)
|
||||
|
||||
## [0.3.3] — 2026-06-03
|
||||
|
||||
### Fixed
|
||||
- **Settings → About showed the wrong architecture in the Docker/web build.**
|
||||
The "Architecture" row rendered the *client browser's* platform
|
||||
(`navigator.platform` → e.g. "Win32"); it now reports the **server's** CPU
|
||||
architecture from the backend (`platform.machine()`), correct for both the
|
||||
desktop app and Docker. The blank version/GPU/RAM/VRAM in the same report
|
||||
were the loopback-gate 403s already fixed in v0.3.2. (#262)
|
||||
|
||||
### CI
|
||||
- The release SHA-256 checksum step no longer uses `mapfile` (a bash 4+
|
||||
builtin) — it broke on the macOS runner's bash 3.2 and dropped the macOS
|
||||
`SHA256SUMS` for v0.3.1/v0.3.2. Now portable to bash 3.2.
|
||||
|
||||
## [0.3.2] — 2026-06-03
|
||||
|
||||
### Fixed
|
||||
- **"Loopback origin required" all over the Docker UI** (and a blank version).
|
||||
The `/system/*` and `/api/settings/*` routes are restricted to a loopback
|
||||
origin, but Docker's NAT makes every request look non-loopback, so the gate
|
||||
403'd the operator out of the admin UI — including `/system/info` (blanking
|
||||
the version) and HF-token entry. The Docker image now runs with
|
||||
`OMNIVOICE_SERVER_MODE=1`, which relaxes the gate for the headless
|
||||
deployment; exposure is governed by the `-p` port mapping plus the optional
|
||||
share PIN. Desktop builds are unaffected — their loopback boundary (and the
|
||||
denial of admin routes to LAN share guests) is unchanged. (#261)
|
||||
|
||||
## [0.3.1] — 2026-06-03
|
||||
|
||||
First tagged build of the 0.3 line off `main` — it ships the accumulated
|
||||
|
||||
@@ -1,136 +1,715 @@
|
||||
# Functional Source License, Version 1.1, ALv2 Future License
|
||||
# OmniVoice Studio — License
|
||||
|
||||
## Abbreviation
|
||||
|
||||
FSL-1.1-ALv2
|
||||
AGPL-3.0-only
|
||||
|
||||
## Notice
|
||||
|
||||
Copyright 2024-present Palash Debnath and OmniVoice Studio contributors.
|
||||
|
||||
OmniVoice Studio is **free for personal, educational, research, and
|
||||
non-commercial use** under the terms below. Two years after each release is
|
||||
published, that release converts automatically to the Apache License,
|
||||
Version 2.0 (see "Grant of Future License").
|
||||
OmniVoice Studio is **free and open-source software, licensed under the GNU
|
||||
Affero General Public License, Version 3 (AGPL-3.0)**. You are free to use,
|
||||
copy, modify, and redistribute it — and that **includes commercial and internal
|
||||
business use**: run the app, use its outputs commercially, sell the audio you
|
||||
produce with it, provide professional/client services with it, and deploy it
|
||||
within your organization.
|
||||
|
||||
**Business / enterprise users** that fall outside the Permitted Purposes
|
||||
below — primarily those building a competing product or service on top of
|
||||
OmniVoice Studio — need a commercial license. Pricing tiers are coming
|
||||
soon. For inquiries in the meantime, contact `OmniVoice@palash.dev`.
|
||||
Because this is the **Affero** GPL, one additional obligation applies: if you
|
||||
modify OmniVoice Studio and make that modified version available to others over
|
||||
a network, you must also offer those users the complete corresponding source
|
||||
code of your modified version under these same AGPL-3.0 terms. See the full
|
||||
text below.
|
||||
|
||||
A **commercial license is available** for organizations that want to embed
|
||||
OmniVoice Studio in a closed-source or proprietary product or service without
|
||||
the AGPL-3.0 copyleft obligations. Pricing tiers are coming soon; for inquiries
|
||||
contact `OmniVoice@palash.dev`.
|
||||
|
||||
(This Notice is a plain-language summary; the binding terms are the full GNU
|
||||
AGPL-3.0 text reproduced below.)
|
||||
|
||||
### Scope
|
||||
|
||||
These terms cover the OmniVoice Studio application — the Tauri desktop
|
||||
shell (`frontend/src-tauri/`), the React frontend (`frontend/src/`), the
|
||||
FastAPI backend (`backend/`), and supporting build / packaging scripts
|
||||
(`scripts/`, `Dockerfile`, `docker-compose.yml`, `.github/`).
|
||||
These terms cover the OmniVoice Studio application — the Tauri desktop shell
|
||||
(`frontend/src-tauri/`), the React frontend (`frontend/src/`), the FastAPI
|
||||
backend (`backend/`), and supporting build / packaging scripts (`scripts/`,
|
||||
`Dockerfile`, `docker-compose.yml`, `.github/`).
|
||||
|
||||
The bundled `omnivoice/` Python package — the underlying TTS model by
|
||||
Han Zhu — is **separately licensed under Apache License 2.0** by its
|
||||
upstream authors and is not relicensed here. See `pyproject.toml`.
|
||||
The bundled `omnivoice/` Python package — the underlying TTS model by Han Zhu —
|
||||
is **separately licensed under Apache License 2.0** by its upstream authors and
|
||||
is not relicensed here. Apache License 2.0 is compatible with, and may be
|
||||
combined under, the GNU AGPL-3.0. See `pyproject.toml`.
|
||||
|
||||
Third-party dependencies retain their own licenses. See `Cargo.lock`,
|
||||
`bun.lock`, and `uv.lock` for the resolved set.
|
||||
|
||||
### Reference
|
||||
|
||||
The full canonical text of the FSL-1.1-ALv2 follows verbatim. The
|
||||
authoritative copy lives at <https://fsl.software/>.
|
||||
The full canonical text of the GNU Affero General Public License, Version 3
|
||||
follows verbatim. The authoritative copy lives at
|
||||
<https://www.gnu.org/licenses/agpl-3.0.txt>.
|
||||
|
||||
---
|
||||
|
||||
## Terms and Conditions
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
### Licensor ("We")
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
The party offering the Software under these Terms and Conditions.
|
||||
Preamble
|
||||
|
||||
### The Software
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The "Software" is each version of the software that we make available under
|
||||
these Terms and Conditions, as indicated by our inclusion of these Terms and
|
||||
Conditions with the Software.
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
### License Grant
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Subject to your compliance with this License Grant and the Patents,
|
||||
Redistribution and Trademark clauses below, we hereby grant you the right to
|
||||
use, copy, modify, create derivative works, publicly perform, publicly display
|
||||
and redistribute the Software for any Permitted Purpose identified below.
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
### Permitted Purpose
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
A Permitted Purpose is any purpose other than a Competing Use. A Competing Use
|
||||
means making the Software available to others in a commercial product or
|
||||
service that:
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
1. substitutes for the Software;
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
2. substitutes for any other product or service we offer using the Software
|
||||
that exists as of the date we make the Software available; or
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
3. offers the same or substantially similar functionality as the Software.
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
Permitted Purposes specifically include using the Software:
|
||||
0. Definitions.
|
||||
|
||||
1. for your internal use and access;
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
2. for non-commercial education;
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
3. for non-commercial research; and
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
4. in connection with professional services that you provide to a licensee
|
||||
using the Software in accordance with these Terms and Conditions.
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
### Patents
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To the extent your use for a Permitted Purpose would necessarily infringe our
|
||||
patents, the license grant above includes a license under our patents. If you
|
||||
make a claim against any party that the Software infringes or contributes to
|
||||
the infringement of any patent, then your patent license to the Software ends
|
||||
immediately.
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
### Redistribution
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
The Terms and Conditions apply to all copies, modifications and derivatives of
|
||||
the Software.
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
If you redistribute any copies, modifications or derivatives of the Software,
|
||||
you must include a copy of or a link to these Terms and Conditions and not
|
||||
remove any copyright notices provided in or with the Software.
|
||||
1. Source Code.
|
||||
|
||||
### Disclaimer
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING WITHOUT LIMITATION WARRANTIES OF FITNESS FOR A PARTICULAR
|
||||
PURPOSE, MERCHANTABILITY, TITLE OR NON-INFRINGEMENT.
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
IN NO EVENT WILL WE HAVE ANY LIABILITY TO YOU ARISING OUT OF OR RELATED TO THE
|
||||
SOFTWARE, INCLUDING INDIRECT, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES,
|
||||
EVEN IF WE HAVE BEEN INFORMED OF THEIR POSSIBILITY IN ADVANCE.
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
### Trademarks
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
Except for displaying the License Details and identifying us as the origin of
|
||||
the Software, you have no right under these Terms and Conditions to use our
|
||||
trademarks, trade names, service marks or product names.
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
## Grant of Future License
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
We hereby irrevocably grant you an additional license to use the Software under
|
||||
the Apache License, Version 2.0 that is effective on the second anniversary of
|
||||
the date we make the Software available. On or after that date, you may use the
|
||||
Software under the Apache License, Version 2.0, in which case the following
|
||||
will apply:
|
||||
2. Basic Permissions.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License"); you may not use
|
||||
this file except in compliance with the License.
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may obtain a copy of the License at
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software distributed
|
||||
under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
|
||||
CONDITIONS OF ANY KIND, either express or implied. See the License for the
|
||||
specific language governing permissions and limitations under the License.
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<p>
|
||||
<a href="https://github.com/debpalash/OmniVoice-Studio/stargazers"><img src="https://img.shields.io/github/stars/debpalash/OmniVoice-Studio?style=flat-square&color=f59e0b" alt="Stars" /></a>
|
||||
<a href="https://github.com/debpalash/OmniVoice-Studio/releases/latest"><img src="https://img.shields.io/github/v/release/debpalash/OmniVoice-Studio?style=flat-square&color=10b981" alt="Release" /></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/license-FSL--1.1--ALv2-blue?style=flat-square" alt="License" /></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/license-AGPL--3.0-blue?style=flat-square" alt="License" /></a>
|
||||
<a href="https://github.com/debpalash/OmniVoice-Studio/issues"><img src="https://img.shields.io/github/issues/debpalash/OmniVoice-Studio?style=flat-square&color=ef4444" alt="Issues" /></a>
|
||||
<a href="https://discord.gg/bzQavDfVV9"><img src="https://img.shields.io/badge/Discord-Join_Community-5865F2?style=flat-square&logo=discord&logoColor=white" alt="Discord" /></a>
|
||||
</p>
|
||||
@@ -29,6 +29,9 @@
|
||||
<a href="https://github.com/debpalash/OmniVoice-Studio/releases/download/v0.2.7/OmniVoice.Studio_0.2.7_amd64.AppImage"><img src="https://img.shields.io/badge/Linux-AppImage_(x64)-FCC624?style=for-the-badge&logo=linux&logoColor=black" alt="Download Linux AppImage" /></a>
|
||||
<a href="https://github.com/debpalash/OmniVoice-Studio/releases/download/v0.2.7/OmniVoice.Studio_0.2.7_amd64.deb"><img src="https://img.shields.io/badge/Debian-.deb-A81D33?style=for-the-badge&logo=debian&logoColor=white" alt="Download Debian .deb" /></a>
|
||||
</p>
|
||||
<p>
|
||||
<sub><b>macOS:</b> first launch needs a one-time approval — right-click → <b>Open</b> (or System Settings → Privacy & Security → <b>"Open Anyway"</b> on macOS 15). No Terminal needed. <a href="docs/install/macos.md#gatekeeper-quarantine">Why?</a></sub>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
@@ -122,9 +125,13 @@ Per-OS install guides — pick yours and follow it end-to-end:
|
||||
- **Linux** — [docs/install/linux.md](docs/install/linux.md)
|
||||
- **Docker** — [docs/install/docker.md](docs/install/docker.md)
|
||||
|
||||
Stuck? See [docs/install/troubleshooting.md](docs/install/troubleshooting.md)
|
||||
for the top 10 install errors. The in-app error UI deeplinks to those entries
|
||||
when something breaks at runtime.
|
||||
Stuck? Run the built-in self-check first — **Settings → About → "Run
|
||||
self-check"** in the app, or `uv run python backend/main.py --diagnose` from
|
||||
a checkout (`--deep` also test-loads the active engine). Then see
|
||||
[docs/install/troubleshooting.md](docs/install/troubleshooting.md) for the
|
||||
top 10 install errors. The in-app error UI deeplinks to those entries when
|
||||
something breaks at runtime, and **Settings → About → "Save diagnostic
|
||||
bundle"** packages scrubbed logs + the self-check report for bug reports.
|
||||
|
||||
For Hugging Face token setup, see
|
||||
[docs/setup/huggingface-token.md](docs/setup/huggingface-token.md). For
|
||||
@@ -187,7 +194,7 @@ ElevenLabs charges **$5–$330/mo** and processes your audio on their servers. O
|
||||
|
||||
| | **ElevenLabs** | **OmniVoice Studio** |
|
||||
|---|---|---|
|
||||
| **Pricing** | $5–$330/mo, per-character billing | Free for personal use · [Commercial license](#license) for business |
|
||||
| **Pricing** | $5–$330/mo, per-character billing | Free & open-source (AGPL-3.0) · [Commercial license](#license) for proprietary use |
|
||||
| **Voice Cloning** | ✅ 3s clip | ✅ 3s clip, zero-shot |
|
||||
| **Voice Design** | ✅ Gender, age | ✅ Gender, age, accent, pitch, style, dialect |
|
||||
| **Languages** | 32 | **646** |
|
||||
@@ -358,7 +365,7 @@ Yes. MPS acceleration is auto-detected. MLX-optimized Whisper models are availab
|
||||
<details>
|
||||
<summary><b>Can I use this commercially?</b></summary>
|
||||
<br/>
|
||||
Personal, educational, internal-team, and non-commercial use is free under <a href="https://fsl.software/">FSL-1.1-ALv2</a>. Building a competing product or service on top of OmniVoice Studio requires a commercial license — see <a href="#license">License</a>. Pricing tiers coming soon. Each release converts to Apache 2.0 two years after publication.
|
||||
<b>Yes — commercial use is free.</b> OmniVoice Studio is free and open-source under the <a href="https://www.gnu.org/licenses/agpl-3.0.html">GNU AGPL-3.0</a>. So personal, educational, research, <b>and commercial / business use are all free</b>: run it, sell the audio you make with it, dub your own or a client's videos, deploy it across your team. Because AGPL is a <b>network copyleft</b> license, if you <b>modify</b> OmniVoice Studio and make that modified version available to others over a network, you must offer those users the source of your modified version under the same AGPL terms. Want to embed OmniVoice in a <b>closed-source or proprietary</b> product without those obligations? A <b>commercial license</b> is available — see <a href="#license">License</a>.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
@@ -377,13 +384,13 @@ Yes. OmniVoice uses a <b>built-in backend registry</b>. To add an engine in ~50
|
||||
|
||||
## License
|
||||
|
||||
OmniVoice Studio is source-available under the [**Functional Source License (FSL-1.1-ALv2)**](https://fsl.software/).
|
||||
OmniVoice Studio is free and open-source software under the [**GNU Affero General Public License v3.0 (AGPL-3.0)**](https://www.gnu.org/licenses/agpl-3.0.html).
|
||||
|
||||
**Free** for personal, educational, research, internal team, and non-commercial use. Each release **converts to Apache 2.0 automatically two years after publication**.
|
||||
**Free for any use — including commercial and internal business use.** Run it, sell the audio you produce with it, dub your own or clients' videos, roll it out across your team — all free, no license needed. As a **network copyleft** license, AGPL adds one obligation: if you **modify** OmniVoice Studio and offer that modified version to others over a network, you must make the complete corresponding source of your modified version available to them under the same AGPL-3.0 terms.
|
||||
|
||||
**Business / enterprise** users building a competing product or service on top of OmniVoice Studio need a commercial license. **Pricing tiers coming soon.** For inquiries in the meantime, reach out at **OmniVoice@palash.dev**.
|
||||
A **commercial license** is available for organizations that want to embed OmniVoice Studio in a **closed-source or proprietary** product or service without the AGPL-3.0 copyleft obligations. **Pricing tiers coming soon.** Inquiries: **OmniVoice@palash.dev**.
|
||||
|
||||
See [`LICENSE`](LICENSE) for the full terms.
|
||||
The bundled `omnivoice/` TTS model by Han Zhu remains Apache-2.0 upstream. See [`LICENSE`](LICENSE) for the full, binding terms.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+7
-5
@@ -7,7 +7,7 @@
|
||||
<p>
|
||||
<a href="https://github.com/debpalash/OmniVoice-Studio/stargazers"><img src="https://img.shields.io/github/stars/debpalash/OmniVoice-Studio?style=flat-square&color=f59e0b" alt="Star" /></a>
|
||||
<a href="https://github.com/debpalash/OmniVoice-Studio/releases/latest"><img src="https://img.shields.io/github/v/release/debpalash/OmniVoice-Studio?style=flat-square&color=10b981" alt="版本" /></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/license-FSL--1.1--ALv2-blue?style=flat-square" alt="许可证" /></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/license-AGPL--3.0-blue?style=flat-square" alt="许可证" /></a>
|
||||
<a href="https://github.com/debpalash/OmniVoice-Studio/issues"><img src="https://img.shields.io/github/issues/debpalash/OmniVoice-Studio?style=flat-square&color=ef4444" alt="Issues" /></a>
|
||||
<a href="https://discord.gg/bzQavDfVV9"><img src="https://img.shields.io/badge/Discord-加入社区-5865F2?style=flat-square&logo=discord&logoColor=white" alt="Discord" /></a>
|
||||
</p>
|
||||
@@ -459,7 +459,7 @@ OmniVoice 配备多引擎 TTS 后端。默认引擎(OmniVoice)始终可用
|
||||
<details>
|
||||
<summary><b>可以用于商业用途吗?</b></summary>
|
||||
<br/>
|
||||
个人、教育、内部团队和非商业用途在 <a href="https://fsl.software/">FSL-1.1-ALv2</a> 下免费。在 OmniVoice Studio 基础上构建竞争产品或服务需要商业许可证——参见<a href="#许可证">许可证</a>。定价方案即将推出。每个版本在发布两年后自动转换为 Apache 2.0。
|
||||
<b>可以——商业使用免费。</b>OmniVoice Studio 是基于 <a href="https://www.gnu.org/licenses/agpl-3.0.html">GNU AGPL-3.0</a> 的自由开源软件。个人、教育、研究<b>以及商业/企业用途均免费</b>:运行它、出售用它生成的音频、为自己或客户的视频配音、在团队中部署。由于 AGPL 是<b>网络著佐权(copyleft)</b>许可证,如果你<b>修改</b>了 OmniVoice Studio 并通过网络向他人提供该修改版本,你必须依据相同的 AGPL 条款向这些用户提供你修改版本的源代码。希望将 OmniVoice 嵌入<b>闭源或专有</b>产品而不受这些义务约束?可获取<b>商业许可证</b>——参见<a href="#许可证">许可证</a>。
|
||||
</details>
|
||||
|
||||
<details>
|
||||
@@ -478,11 +478,13 @@ OmniVoice 配备多引擎 TTS 后端。默认引擎(OmniVoice)始终可用
|
||||
|
||||
## 许可证
|
||||
|
||||
OmniVoice Studio 在 [**Functional Source License (FSL-1.1-ALv2)**](https://fsl.software/) 下提供源码。
|
||||
OmniVoice Studio 是基于 [**GNU Affero 通用公共许可证 v3.0(AGPL-3.0)**](https://www.gnu.org/licenses/agpl-3.0.html) 的自由开源软件。
|
||||
|
||||
**免费**用于个人、教育、研究、内部团队和非商业用途。每个版本在**发布两年后自动转换为 Apache 2.0**。
|
||||
**可免费用于任何用途——包括商业和企业内部用途。** 运行它、出售用它生成的音频、为自己或客户的视频配音、在团队中推广——全部免费,无需许可证。作为**网络著佐权(copyleft)**许可证,AGPL 增加了一项义务:如果你**修改**了 OmniVoice Studio 并通过网络向他人提供该修改版本,你必须依据相同的 AGPL-3.0 条款向他们提供该修改版本的完整对应源代码。
|
||||
|
||||
**商业/企业**用户在 OmniVoice Studio 基础上构建竞争产品或服务需要商业许可证。**定价方案即将推出。** 在此期间如有疑问,请发送邮件至 **OmniVoice@palash.dev**。
|
||||
希望将 OmniVoice Studio 嵌入**闭源或专有**产品或服务、又不受 AGPL-3.0 著佐权义务约束的组织,可获取**商业许可证**。**定价方案即将推出。** 如有疑问:**OmniVoice@palash.dev**。
|
||||
|
||||
捆绑的 `omnivoice/`(由朱涵开发的 TTS 模型)在上游仍为 Apache-2.0 许可。完整且具约束力的条款请参见 [`LICENSE`](LICENSE)。
|
||||
|
||||
参见 [`LICENSE`](LICENSE) 查看完整条款。
|
||||
|
||||
|
||||
@@ -5,9 +5,12 @@ These are intentionally tiny — one concern per dependency — so they can be
|
||||
composed at the route or router level without surprises.
|
||||
|
||||
Currently exposed:
|
||||
- `require_loopback`: 403 unless the request came from a loopback origin.
|
||||
- `require_loopback`: 403 unless the request came from a loopback origin
|
||||
(bypassed in explicit server mode — see `_server_mode`).
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
|
||||
@@ -19,6 +22,28 @@ from fastapi import HTTPException, Request
|
||||
# the guard: nothing here matches a non-loopback origin.
|
||||
_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost"})
|
||||
|
||||
_TRUTHY = frozenset({"1", "true", "yes", "on"})
|
||||
|
||||
|
||||
def _server_mode() -> bool:
|
||||
"""Whether this process is a headless server deployment (Docker image).
|
||||
|
||||
In Docker the loopback gate is *unenforceable*: Docker's network NAT
|
||||
rewrites ``request.client.host`` to the bridge gateway (e.g. 172.17.0.1)
|
||||
even for a localhost-only ``-p 127.0.0.1:3900:3900`` mapping, so every
|
||||
request looks non-loopback and the gate 403s the operator out of the
|
||||
system/settings routes they need (issue #261 — incl. ``/system/info``,
|
||||
which blanks the version display).
|
||||
|
||||
The Docker image sets ``OMNIVOICE_SERVER_MODE=1`` to opt out of the gate.
|
||||
Network exposure then rests on the operator's port mapping plus the
|
||||
optional share PIN (``NetworkAccessMiddleware`` still 401s unauthenticated
|
||||
non-loopback clients whenever a PIN is set). The desktop build never sets
|
||||
this, so its loopback boundary — including denying LAN share guests access
|
||||
to admin routes — is unchanged. Read at call time so it stays testable.
|
||||
"""
|
||||
return os.environ.get("OMNIVOICE_SERVER_MODE", "").strip().lower() in _TRUTHY
|
||||
|
||||
|
||||
def require_loopback(request: Request) -> None:
|
||||
"""Reject any request whose `client.host` is not a loopback address.
|
||||
@@ -35,7 +60,14 @@ def require_loopback(request: Request) -> None:
|
||||
Returns None on success (FastAPI dependency convention). Raises 403
|
||||
on rejection — the response body is `{"detail": "loopback origin required"}`
|
||||
so existing tests for `/system/set-env` keep passing without modification.
|
||||
|
||||
In server mode (Docker, see `_server_mode`) the gate is a no-op: the
|
||||
loopback origin is unenforceable there and exposure is governed by the
|
||||
deployment's port mapping + the optional share PIN instead.
|
||||
"""
|
||||
host = request.client.host if request.client else None
|
||||
if host not in _LOOPBACK_HOSTS:
|
||||
raise HTTPException(status_code=403, detail="loopback origin required")
|
||||
if host in _LOOPBACK_HOSTS:
|
||||
return
|
||||
if _server_mode():
|
||||
return
|
||||
raise HTTPException(status_code=403, detail="loopback origin required")
|
||||
|
||||
@@ -366,14 +366,28 @@ _prep_event_helper = dub_pipeline.prep_event # alias; we keep the module-local
|
||||
|
||||
|
||||
@router.get("/dub/transcribe-stream/{job_id}")
|
||||
async def dub_transcribe_stream(job_id: str):
|
||||
async def dub_transcribe_stream(job_id: str, num_speakers: Optional[int] = None):
|
||||
"""Stream per-chunk segments via SSE, then emit diarized final pass.
|
||||
|
||||
Pre-flight checks (missing job, missing audio, ASR not loaded) are emitted
|
||||
as in-stream `error` events rather than HTTP errors, because EventSource
|
||||
on the client can't read non-2xx response bodies — a 503 there surfaces
|
||||
as an opaque "network error" instead of the actionable message we want.
|
||||
|
||||
`num_speakers` is an optional hint passed straight to pyannote. Left unset,
|
||||
pyannote auto-detects the count — but its auto-detect can collapse a
|
||||
multi-speaker clip to a single speaker (issue #274). When the user knows
|
||||
the exact count, supplying it forces pyannote to return that many speakers.
|
||||
"""
|
||||
# Clamp to a sane range; ignore anything non-positive / absurd so a bad
|
||||
# query string can never break the diarization call. None → auto-detect.
|
||||
if num_speakers is not None:
|
||||
try:
|
||||
num_speakers = int(num_speakers)
|
||||
num_speakers = num_speakers if 1 <= num_speakers <= 20 else None
|
||||
except (TypeError, ValueError):
|
||||
num_speakers = None
|
||||
|
||||
job = _get_job(job_id)
|
||||
|
||||
preflight_error: Optional[str] = None
|
||||
@@ -404,12 +418,11 @@ async def dub_transcribe_stream(job_id: str):
|
||||
else:
|
||||
from services.asr_backend import get_active_asr_backend
|
||||
try:
|
||||
# The PyTorch-Whisper backend lazily builds its own pipeline
|
||||
# when no preloaded `_asr_pipe` is present (issue #255), so it
|
||||
# no longer needs OMNIVOICE_PRELOAD_TTS_ASR=1 — don't reject it
|
||||
# here; any load failure surfaces per-chunk with a real cause.
|
||||
_asr_backend = get_active_asr_backend(asr_pipe=getattr(_model, "_asr_pipe", None))
|
||||
if _asr_backend.id == "pytorch-whisper" and getattr(_model, "_asr_pipe", None) is None:
|
||||
preflight_error = (
|
||||
"No ASR backend is ready. Install WhisperX/faster-whisper/MLX Whisper "
|
||||
"or set OMNIVOICE_PRELOAD_TTS_ASR=1 before launch to use the PyTorch fallback."
|
||||
)
|
||||
except Exception as e:
|
||||
from core.failure import build_failure
|
||||
f = build_failure(e, stage="transcribe-preflight", include_diagnostic=False)
|
||||
@@ -672,7 +685,15 @@ async def dub_transcribe_stream(job_id: str):
|
||||
},
|
||||
)
|
||||
try:
|
||||
diar = diar_pipe(asr_audio_target)
|
||||
# Pass the user's speaker-count hint through to pyannote when
|
||||
# provided (#274). pyannote's apply() accepts num_speakers;
|
||||
# omit it entirely when None so we don't depend on the kwarg
|
||||
# existing in every pyannote build.
|
||||
if num_speakers:
|
||||
logger.info("Diarizing with num_speakers=%d (user hint)", num_speakers)
|
||||
diar = diar_pipe(asr_audio_target, num_speakers=num_speakers)
|
||||
else:
|
||||
diar = diar_pipe(asr_audio_target)
|
||||
return assign_speakers_from_diarization(all_segments, diar), None
|
||||
except Exception as e:
|
||||
logger.error(f"Diarization failed: {e}")
|
||||
|
||||
@@ -20,6 +20,45 @@ from core import event_bus
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger("omnivoice.generate")
|
||||
|
||||
|
||||
def _render_with_pauses(gen_span, segments, sample_rate):
|
||||
"""Synthesize ``[(text, pause_ms), ...]`` spans and stitch silence between
|
||||
them (issue #276).
|
||||
|
||||
``gen_span(text) -> torch.Tensor`` synthesizes one text span (raw model
|
||||
output). A silence buffer of ``pause_ms`` is inserted after a span when
|
||||
requested, matching the audio tensor's channel dims / dtype / device.
|
||||
Returns the concatenated waveform. Kept model-free (``gen_span`` is injected)
|
||||
so the stitching is unit-testable without loading the TTS model.
|
||||
"""
|
||||
import torch
|
||||
|
||||
items = [] # ('a', tensor) for audio, ('s', n_samples) for silence
|
||||
for span_text, pause_ms in segments:
|
||||
if span_text and span_text.strip():
|
||||
items.append(("a", gen_span(span_text)))
|
||||
if pause_ms > 0:
|
||||
n = int(round(sample_rate * pause_ms / 1000.0))
|
||||
if n > 0:
|
||||
items.append(("s", n))
|
||||
|
||||
ref = next((t for kind, t in items if kind == "a"), None)
|
||||
if ref is None:
|
||||
# No speakable text (e.g. the input was only pause markers) — emit the
|
||||
# requested silence so the caller still gets a valid clip.
|
||||
total = sum(n for kind, n in items if kind == "s") or 1
|
||||
return torch.zeros(total, dtype=torch.float32)
|
||||
|
||||
parts = []
|
||||
for kind, val in items:
|
||||
if kind == "a":
|
||||
parts.append(val)
|
||||
else:
|
||||
shape = list(ref.shape)
|
||||
shape[-1] = val
|
||||
parts.append(torch.zeros(*shape, dtype=ref.dtype, device=ref.device))
|
||||
return torch.cat(parts, dim=-1)
|
||||
|
||||
def _run_inference(
|
||||
model, text, language, ref_audio_path, ref_text, instruct, duration,
|
||||
num_step, guidance_scale, speed, t_shift, denoise,
|
||||
@@ -38,17 +77,37 @@ def _run_inference(
|
||||
if position_temperature is not None: kwargs["position_temperature"] = position_temperature
|
||||
if class_temperature is not None: kwargs["class_temperature"] = class_temperature
|
||||
|
||||
audios = model.generate(
|
||||
text=text, language=language, ref_audio=ref_audio_path,
|
||||
ref_text=ref_text, instruct=instruct, duration=duration,
|
||||
num_step=num_step, guidance_scale=guidance_scale, speed=speed,
|
||||
denoise=denoise, postprocess_output=postprocess_output,
|
||||
**kwargs
|
||||
)
|
||||
audio_out = audios[0]
|
||||
|
||||
sr = model.sampling_rate if hasattr(model, 'sampling_rate') else 24000
|
||||
|
||||
# Inline [pause Nms] markers (issue #276): split the text and stitch
|
||||
# silence between independently-synthesized spans. Fully opt-in — text
|
||||
# without a marker takes the unchanged single-shot path below.
|
||||
from omnivoice.utils.text import parse_pause_markers
|
||||
segments = parse_pause_markers(text)
|
||||
has_pause = len(segments) > 1 or (segments and segments[0][1] > 0)
|
||||
|
||||
if has_pause:
|
||||
def _gen_span(span_text):
|
||||
# Per-span duration is left to the model; an explicit overall
|
||||
# `duration` can't be meaningfully split across spans.
|
||||
return model.generate(
|
||||
text=span_text, language=language, ref_audio=ref_audio_path,
|
||||
ref_text=ref_text, instruct=instruct, duration=None,
|
||||
num_step=num_step, guidance_scale=guidance_scale, speed=speed,
|
||||
denoise=denoise, postprocess_output=postprocess_output,
|
||||
**kwargs
|
||||
)[0]
|
||||
audio_out = _render_with_pauses(_gen_span, segments, sr)
|
||||
else:
|
||||
audios = model.generate(
|
||||
text=text, language=language, ref_audio=ref_audio_path,
|
||||
ref_text=ref_text, instruct=instruct, duration=duration,
|
||||
num_step=num_step, guidance_scale=guidance_scale, speed=speed,
|
||||
denoise=denoise, postprocess_output=postprocess_output,
|
||||
**kwargs
|
||||
)
|
||||
audio_out = audios[0]
|
||||
|
||||
# Apply DSP effect preset
|
||||
_effect_preset = effect_preset or "broadcast"
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import os
|
||||
import sys
|
||||
import platform
|
||||
import time
|
||||
import uuid
|
||||
import psutil
|
||||
import asyncio
|
||||
@@ -39,6 +41,57 @@ _is_cuda = torch.cuda.is_available()
|
||||
psutil.cpu_percent(interval=None)
|
||||
|
||||
|
||||
def _detect_cpu_model() -> str:
|
||||
"""Human-readable CPU model. platform.processor() is empty on most
|
||||
Linux distros, so read /proc/cpuinfo there; sysctl on macOS."""
|
||||
try:
|
||||
if sys.platform.startswith("linux"):
|
||||
with open("/proc/cpuinfo") as f:
|
||||
for line in f:
|
||||
if line.lower().startswith("model name"):
|
||||
return line.split(":", 1)[1].strip()
|
||||
if sys.platform == "darwin":
|
||||
import subprocess
|
||||
return subprocess.check_output(
|
||||
["sysctl", "-n", "machdep.cpu.brand_string"], text=True, timeout=5
|
||||
).strip()
|
||||
return platform.processor() or ""
|
||||
except Exception:
|
||||
return platform.processor() or ""
|
||||
|
||||
|
||||
def _detect_gpu() -> tuple[str, float]:
|
||||
"""(gpu_name, vram_total_gb) — static for the process lifetime.
|
||||
|
||||
MPS has unified memory, so there's no separate VRAM figure to report;
|
||||
the name alone tells a bug-report reader what hardware this is.
|
||||
"""
|
||||
try:
|
||||
if _is_cuda:
|
||||
props = torch.cuda.get_device_properties(0)
|
||||
return torch.cuda.get_device_name(0), round(props.total_memory / (1024 ** 3), 1)
|
||||
if _is_mac:
|
||||
return "Apple Silicon (MPS)", 0.0
|
||||
except Exception:
|
||||
pass
|
||||
return "", 0.0
|
||||
|
||||
|
||||
# Static hardware facts, captured once — /system/info is hit on every
|
||||
# Settings page load and must stay cheap.
|
||||
_CPU_MODEL = _detect_cpu_model()
|
||||
_GPU_NAME, _VRAM_TOTAL_GB = _detect_gpu()
|
||||
_RAM_TOTAL_GB = round(psutil.virtual_memory().total / (1024 ** 3), 1)
|
||||
_OS_VERSION = platform.platform()
|
||||
|
||||
|
||||
def _disk_free_gb() -> float:
|
||||
try:
|
||||
return round(shutil.disk_usage(DATA_DIR).free / (1024 ** 3), 1)
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
|
||||
def _ui_port() -> int:
|
||||
"""The Vite UI dev-server port, single-sourced from OMNIVOICE_UI_PORT.
|
||||
|
||||
@@ -181,6 +234,14 @@ def system_info():
|
||||
"device": get_best_device(),
|
||||
"python": sys.version.split()[0],
|
||||
"platform": sys.platform,
|
||||
"arch": platform.machine(),
|
||||
"os_version": _OS_VERSION,
|
||||
"cpu_model": _CPU_MODEL,
|
||||
"cpu_count": psutil.cpu_count(logical=True) or 0,
|
||||
"ram_total_gb": _RAM_TOTAL_GB,
|
||||
"gpu_name": _GPU_NAME,
|
||||
"vram_total_gb": _VRAM_TOTAL_GB,
|
||||
"disk_free_gb": _disk_free_gb(),
|
||||
"ffmpeg_ok": bool(_ffmpeg),
|
||||
"ffmpeg_path": _ffmpeg or "",
|
||||
"proxy_url": os.environ.get("HTTP_PROXY") or os.environ.get("http_proxy") or "",
|
||||
@@ -207,6 +268,14 @@ def system_info():
|
||||
"device": "cpu",
|
||||
"python": sys.version.split()[0],
|
||||
"platform": sys.platform,
|
||||
"arch": platform.machine(),
|
||||
"os_version": _OS_VERSION,
|
||||
"cpu_model": _CPU_MODEL,
|
||||
"cpu_count": psutil.cpu_count(logical=True) or 0,
|
||||
"ram_total_gb": _RAM_TOTAL_GB,
|
||||
"gpu_name": _GPU_NAME,
|
||||
"vram_total_gb": _VRAM_TOTAL_GB,
|
||||
"disk_free_gb": _disk_free_gb(),
|
||||
"proxy_url": "",
|
||||
"share_enabled": network_share.get_state().enabled,
|
||||
"share_port": network_share.get_state().share_port,
|
||||
@@ -229,11 +298,19 @@ def _tail_file(path: str, tail: int):
|
||||
def _tauri_log_candidates():
|
||||
"""Likely paths for Tauri-side logs, most useful first.
|
||||
|
||||
`tauri-plugin-log` writes to `~/Library/Logs/<bundle_id>/<file_name>.log`
|
||||
by default on macOS. Our bundle id is `com.debpalash.omnivoice-studio`
|
||||
(see frontend/src-tauri/tauri.conf.json). lib.rs also redirects the
|
||||
spawned backend's stdout/stderr to `~/Library/Logs/OmniVoice/backend.log`
|
||||
which is where `print()` calls and uvicorn startup banners land.
|
||||
Two distinct producers, both per-platform:
|
||||
|
||||
- `tauri-plugin-log` writes `tauri.log` to the app log dir
|
||||
(`~/Library/Logs/<bundle_id>` on macOS, `$XDG_DATA_HOME/<bundle_id>/logs`
|
||||
on Linux, `%LOCALAPPDATA%\\<bundle_id>\\logs` on Windows). Bundle id is
|
||||
`com.debpalash.omnivoice-studio` (frontend/src-tauri/tauri.conf.json).
|
||||
- backend.rs::backend_log_path() redirects the spawned backend's
|
||||
stdout/stderr to `backend.log` / `backend_err.log` under
|
||||
`~/Library/Logs/OmniVoice` (macOS), `$XDG_STATE_HOME/OmniVoice` falling
|
||||
back to `~/.local/state/OmniVoice` (Linux), and
|
||||
`%LOCALAPPDATA%\\OmniVoice\\Logs` (Windows). This is where uvicorn
|
||||
startup banners and hard-crash tracebacks land — keep all three OS
|
||||
shapes listed or sidecar crashes become invisible off-macOS.
|
||||
"""
|
||||
home = os.path.expanduser("~")
|
||||
bid = "com.debpalash.omnivoice-studio"
|
||||
@@ -245,14 +322,21 @@ def _tauri_log_candidates():
|
||||
os.path.join(home, "Library/Logs/OmniVoice/backend_err.log"),
|
||||
]
|
||||
if sys.platform.startswith("linux"):
|
||||
state_dir = os.environ.get("XDG_STATE_HOME") or os.path.join(home, ".local/state")
|
||||
return [
|
||||
os.path.join(home, ".local/share", bid, "logs", "tauri.log"),
|
||||
os.path.join(home, ".config", bid, "logs", "tauri.log"),
|
||||
os.path.join(state_dir, "OmniVoice", "backend.log"),
|
||||
os.path.join(state_dir, "OmniVoice", "backend_err.log"),
|
||||
]
|
||||
if sys.platform.startswith("win"):
|
||||
appdata = os.environ.get("APPDATA", home)
|
||||
localappdata = os.environ.get("LOCALAPPDATA") or os.path.join(home, "AppData", "Local")
|
||||
return [
|
||||
os.path.join(localappdata, bid, "logs", "tauri.log"),
|
||||
os.path.join(appdata, bid, "logs", "tauri.log"),
|
||||
os.path.join(localappdata, "OmniVoice", "Logs", "backend.log"),
|
||||
os.path.join(localappdata, "OmniVoice", "Logs", "backend_err.log"),
|
||||
]
|
||||
return []
|
||||
|
||||
@@ -566,9 +650,57 @@ def system_notifications():
|
||||
"action": None,
|
||||
})
|
||||
|
||||
# 5. A previous session logged a crash the user never saw.
|
||||
# crash_log grew past the last acknowledged size AND predates this
|
||||
# process — i.e. it happened last run, not just now (errors from the
|
||||
# current session already surfaced as toasts).
|
||||
try:
|
||||
if _crashed_last_session():
|
||||
notes.append({
|
||||
"id": "crash-last-session",
|
||||
"level": "error",
|
||||
"title": "Last session ended with an error",
|
||||
"message": (
|
||||
"A crash was logged before this session started. "
|
||||
"Review the backend log and consider filing a report."
|
||||
),
|
||||
"action": {
|
||||
"label": "View logs",
|
||||
"type": "navigate",
|
||||
"target": "settings",
|
||||
},
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {"notifications": notes, "count": len(notes)}
|
||||
|
||||
|
||||
# Process start time — anchors "did the crash happen before this run?".
|
||||
_PROCESS_START_TS = time.time()
|
||||
|
||||
|
||||
def _crashed_last_session() -> bool:
|
||||
from core.prefs import get as prefs_get
|
||||
|
||||
if not os.path.exists(CRASH_LOG_PATH):
|
||||
return False
|
||||
size = os.path.getsize(CRASH_LOG_PATH)
|
||||
acked = int(prefs_get("crash_log_acked_size", 0) or 0)
|
||||
if size <= acked:
|
||||
return False
|
||||
return os.path.getmtime(CRASH_LOG_PATH) < _PROCESS_START_TS
|
||||
|
||||
|
||||
@router.post("/system/crash/ack")
|
||||
async def ack_crash():
|
||||
"""Mark the current crash log as seen — dismisses the
|
||||
'crash-last-session' notification until the log grows again."""
|
||||
size = os.path.getsize(CRASH_LOG_PATH) if os.path.exists(CRASH_LOG_PATH) else 0
|
||||
prefs_set("crash_log_acked_size", size)
|
||||
return {"acked_size": size}
|
||||
|
||||
|
||||
# ── Environment variable setter ───────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -786,6 +918,59 @@ def hf_token_state():
|
||||
}
|
||||
|
||||
|
||||
# ── Error journal ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/system/errors/recent")
|
||||
def recent_errors(limit: int = Query(20, ge=1, le=50)):
|
||||
"""Recent unhandled backend errors, newest first — structured, deduped
|
||||
(count per fingerprint), classified (error_class), pre-scrubbed. The
|
||||
bug-report pipeline reads this to auto-attach the most recent backend
|
||||
failure; Settings → Logs can render it as a triage view.
|
||||
"""
|
||||
from core import error_journal
|
||||
|
||||
errors = error_journal.recent(limit)
|
||||
return {"errors": errors, "count": len(errors)}
|
||||
|
||||
|
||||
# ── Diagnostic bundle ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/system/diagnostic-bundle")
|
||||
async def diagnostic_bundle(network: bool = Query(False, description="Include the hub reachability probe")):
|
||||
"""Build the drag-onto-a-GitHub-issue zip (core.diagnostic_bundle):
|
||||
self-check report, recent error journal, scrubbed log tails. Returns the
|
||||
local path so the UI can reveal it in the file manager. The path itself
|
||||
is NOT scrubbed — this response never leaves the machine; the zip's
|
||||
*contents* are scrubbed because the zip does.
|
||||
"""
|
||||
from core.diagnostic_bundle import build_bundle
|
||||
|
||||
path = await asyncio.to_thread(build_bundle, network)
|
||||
return {"path": path, "filename": os.path.basename(path)}
|
||||
|
||||
|
||||
# ── Self-check diagnostics ────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/system/diagnose")
|
||||
async def system_diagnose(
|
||||
network: bool = Query(True, description="Include the HuggingFace hub reachability probe"),
|
||||
deep: bool = Query(False, description="Also load the active engine and synthesize a short utterance (may cold-load the model — minutes on first run)"),
|
||||
):
|
||||
"""Run the self-check suite (core.diagnose) and return the structured report.
|
||||
|
||||
The hub probe can block up to ~5s (and ``deep=true`` far longer), so the
|
||||
whole run goes through a threadpool; pass ``network=false`` for an
|
||||
instant offline report. Output is pre-scrubbed (core.scrub) — safe to
|
||||
paste into a GitHub issue.
|
||||
"""
|
||||
from core.diagnose import run_diagnostics
|
||||
|
||||
return await asyncio.to_thread(run_diagnostics, network, deep)
|
||||
|
||||
|
||||
# ── Phase 1 Wave 3 — macOS Gatekeeper quarantine probe (#54) ────────────
|
||||
|
||||
|
||||
|
||||
@@ -37,6 +37,14 @@ class SystemInfoResponse(BaseModel):
|
||||
device: str = "cpu"
|
||||
python: str = ""
|
||||
platform: str = ""
|
||||
arch: str = ""
|
||||
os_version: str = ""
|
||||
cpu_model: str = ""
|
||||
cpu_count: int = 0
|
||||
ram_total_gb: float = 0.0
|
||||
gpu_name: str = ""
|
||||
vram_total_gb: float = 0.0
|
||||
disk_free_gb: float = 0.0
|
||||
error: str | None = None
|
||||
ffmpeg_ok: bool = False
|
||||
ffmpeg_path: str = ""
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
"""Self-check diagnostics — answers "why doesn't it work on my machine?"
|
||||
|
||||
One pass over everything a working install needs: Python, compute device,
|
||||
ffmpeg, HF token, disk, data-dir permissions, RAM, TTS engines, and (when
|
||||
requested) network reachability of the HuggingFace hub. Surfaced two ways:
|
||||
|
||||
- ``GET /system/diagnose`` (Settings > About → "Run self-check")
|
||||
- ``python main.py --diagnose`` for headless installs / issue triage
|
||||
|
||||
Every ``detail``/``hint`` string is passed through ``core.scrub`` before it
|
||||
leaves this module, so the report is safe to paste straight into a GitHub
|
||||
issue — that's its whole purpose.
|
||||
|
||||
Check shape:
|
||||
|
||||
{"id": str, "label": str, "status": "ok"|"warn"|"fail",
|
||||
"detail": str, "hint": Optional[str]}
|
||||
|
||||
``fail`` = the app cannot do its job (no disk, unwritable data dir).
|
||||
``warn`` = degraded but usable (CPU-only, no HF token, hub unreachable).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
from core.config import DATA_DIR
|
||||
from core.scrub import scrub_text
|
||||
from core.version import APP_VERSION
|
||||
|
||||
OK = "ok"
|
||||
WARN = "warn"
|
||||
FAIL = "fail"
|
||||
|
||||
# Below this much free disk the model cache can't even hold one engine.
|
||||
_DISK_FAIL_GB = 2
|
||||
_DISK_WARN_GB = 10
|
||||
_RAM_WARN_GB = 8
|
||||
|
||||
_HUB_URL = "https://huggingface.co"
|
||||
_HUB_TIMEOUT_S = 5
|
||||
|
||||
|
||||
def _check(check_id: str, label: str, status: str, detail: str, hint: str | None = None) -> dict:
|
||||
return {
|
||||
"id": check_id,
|
||||
"label": label,
|
||||
"status": status,
|
||||
"detail": scrub_text(detail),
|
||||
"hint": scrub_text(hint) if hint else None,
|
||||
}
|
||||
|
||||
|
||||
def _check_python() -> dict:
|
||||
return _check(
|
||||
"python", "Python runtime", OK,
|
||||
f"{sys.version.split()[0]} on {platform.platform()}",
|
||||
)
|
||||
|
||||
|
||||
def _check_device() -> dict:
|
||||
try:
|
||||
from services.model_manager import get_best_device
|
||||
device = get_best_device()
|
||||
except Exception as e:
|
||||
return _check(
|
||||
"device", "Compute device", FAIL,
|
||||
f"device detection failed: {e}",
|
||||
"Reinstall may be needed - torch could not initialize.",
|
||||
)
|
||||
gpu_name = ""
|
||||
try:
|
||||
import torch
|
||||
if torch.cuda.is_available():
|
||||
gpu_name = torch.cuda.get_device_name(0)
|
||||
except Exception:
|
||||
pass
|
||||
if device == "cpu":
|
||||
return _check(
|
||||
"device", "Compute device", WARN,
|
||||
"cpu (no GPU acceleration detected)",
|
||||
"Generation will be slow. If this machine has a GPU, check CUDA/ROCm drivers (Linux/Windows) or that you're on Apple Silicon (macOS).",
|
||||
)
|
||||
detail = f"{device} ({gpu_name})" if gpu_name else device
|
||||
return _check("device", "Compute device", OK, detail)
|
||||
|
||||
|
||||
def _check_ffmpeg() -> dict:
|
||||
try:
|
||||
from services.ffmpeg_utils import find_ffmpeg
|
||||
path = find_ffmpeg()
|
||||
except Exception:
|
||||
path = None
|
||||
if path:
|
||||
return _check("ffmpeg", "ffmpeg", OK, str(path))
|
||||
return _check(
|
||||
"ffmpeg", "ffmpeg", FAIL,
|
||||
"not found on PATH or FFMPEG_PATH",
|
||||
"Dubbing and audio conversion need ffmpeg: brew install ffmpeg (macOS), apt install ffmpeg (Linux), or set the path in Settings > General.",
|
||||
)
|
||||
|
||||
|
||||
def _check_hf_token() -> dict:
|
||||
# Presence only — the resolver never hands us the raw token and we
|
||||
# wouldn't print it anyway.
|
||||
try:
|
||||
from services import token_resolver
|
||||
present = token_resolver.resolve() is not None
|
||||
except Exception:
|
||||
present = False
|
||||
if present:
|
||||
return _check("hf_token", "HuggingFace token", OK, "configured")
|
||||
return _check(
|
||||
"hf_token", "HuggingFace token", WARN,
|
||||
"not set",
|
||||
"Downloads may be rate-limited and speaker diarization won't work. Set one in Settings > Credentials.",
|
||||
)
|
||||
|
||||
|
||||
def _check_disk() -> dict:
|
||||
try:
|
||||
usage = shutil.disk_usage(DATA_DIR)
|
||||
except Exception as e:
|
||||
return _check("disk", "Disk space", WARN, f"could not stat {DATA_DIR}: {e}")
|
||||
free_gb = usage.free / (1024 ** 3)
|
||||
detail = f"{free_gb:.1f} GB free at {DATA_DIR}"
|
||||
if free_gb < _DISK_FAIL_GB:
|
||||
return _check(
|
||||
"disk", "Disk space", FAIL, detail,
|
||||
"Model downloads need several GB. Free up space or move OMNIVOICE_DATA_DIR to a larger volume.",
|
||||
)
|
||||
if free_gb < _DISK_WARN_GB:
|
||||
return _check(
|
||||
"disk", "Disk space", WARN, detail,
|
||||
"Engine model downloads can be 1-4 GB each; you may run out mid-download.",
|
||||
)
|
||||
return _check("disk", "Disk space", OK, detail)
|
||||
|
||||
|
||||
def _check_data_dir() -> dict:
|
||||
probe = os.path.join(DATA_DIR, ".diagnose_write_probe")
|
||||
try:
|
||||
with open(probe, "w") as f:
|
||||
f.write("ok")
|
||||
os.remove(probe)
|
||||
return _check("data_dir", "Data directory", OK, f"writable: {DATA_DIR}")
|
||||
except Exception as e:
|
||||
return _check(
|
||||
"data_dir", "Data directory", FAIL,
|
||||
f"not writable: {DATA_DIR} ({e})",
|
||||
"Voices, projects, and logs all live here. Fix permissions or point OMNIVOICE_DATA_DIR somewhere writable.",
|
||||
)
|
||||
|
||||
|
||||
def _check_ram() -> dict:
|
||||
try:
|
||||
import psutil
|
||||
total_gb = psutil.virtual_memory().total / (1024 ** 3)
|
||||
except Exception as e:
|
||||
return _check("ram", "System memory", WARN, f"could not read: {e}")
|
||||
detail = f"{total_gb:.1f} GB total"
|
||||
if total_gb < _RAM_WARN_GB:
|
||||
return _check(
|
||||
"ram", "System memory", WARN, detail,
|
||||
"Large engines may swap or OOM below 8 GB. Prefer lighter engines and close other apps while generating.",
|
||||
)
|
||||
return _check("ram", "System memory", OK, detail)
|
||||
|
||||
|
||||
def _check_engines() -> dict:
|
||||
try:
|
||||
from services.tts_backend import list_backends, active_backend_id
|
||||
backends = list_backends()
|
||||
active = active_backend_id()
|
||||
except Exception as e:
|
||||
return _check("engines", "TTS engines", WARN, f"could not enumerate: {e}")
|
||||
available = [b["id"] for b in backends if b.get("available")]
|
||||
detail = f"active: {active}; available: {', '.join(available) or 'none'}"
|
||||
active_row = next((b for b in backends if b.get("id") == active), None)
|
||||
if active_row is not None and not active_row.get("available"):
|
||||
reason = active_row.get("reason") or "unavailable"
|
||||
return _check(
|
||||
"engines", "TTS engines", FAIL,
|
||||
f"{detail} - active engine '{active}' is unavailable: {reason}",
|
||||
active_row.get("install_hint") or "Pick a different engine in Settings > Engines.",
|
||||
)
|
||||
if not available:
|
||||
return _check(
|
||||
"engines", "TTS engines", FAIL, detail,
|
||||
"No usable TTS engine. Install one from Settings > Engines.",
|
||||
)
|
||||
return _check("engines", "TTS engines", OK, detail)
|
||||
|
||||
|
||||
_DEEP_TIMEOUT_S = 180
|
||||
|
||||
|
||||
def _check_deep_synthesis() -> dict:
|
||||
"""Actually load the active engine and synthesize a short utterance.
|
||||
|
||||
Catches "installed but broken" — the most common issue category — which
|
||||
the presence checks above can't see. Opt-in only (?deep=true / --deep):
|
||||
it may cold-load the model (minutes + a multi-GB download on a fresh
|
||||
install), so it must never run on a casual Settings-page self-check.
|
||||
"""
|
||||
try:
|
||||
from services.model_manager import get_model_status
|
||||
if get_model_status().get("status") == "loading":
|
||||
return _check(
|
||||
"deep_synth", "Deep synthesis", WARN,
|
||||
"skipped - a model load is already in progress",
|
||||
"Re-run once the current load finishes.",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
import concurrent.futures
|
||||
import time as _time
|
||||
|
||||
def _synth():
|
||||
import services.model_manager as mm
|
||||
from services.tts_backend import get_active_tts_backend, active_backend_id
|
||||
backend = get_active_tts_backend(model=mm.model)
|
||||
wav = backend.generate("Diagnostics check, one two three.", num_step=4)
|
||||
return active_backend_id(), int(wav.shape[-1]) / max(1, backend.sample_rate)
|
||||
|
||||
t0 = _time.perf_counter()
|
||||
ex = concurrent.futures.ThreadPoolExecutor(max_workers=1)
|
||||
try:
|
||||
engine_id, audio_s = ex.submit(_synth).result(timeout=_DEEP_TIMEOUT_S)
|
||||
except concurrent.futures.TimeoutError:
|
||||
return _check(
|
||||
"deep_synth", "Deep synthesis", FAIL,
|
||||
f"timed out after {_DEEP_TIMEOUT_S}s - engine load or synthesis hung",
|
||||
"If this is a first run, the model may still be downloading - retry later. Otherwise check the backend log for where it stalled.",
|
||||
)
|
||||
except Exception as e:
|
||||
return _check(
|
||||
"deep_synth", "Deep synthesis", FAIL,
|
||||
f"active engine failed: {type(e).__name__}: {e}",
|
||||
"The engine is installed but not producing audio. The error above is the lead; Settings > Logs has the full trace.",
|
||||
)
|
||||
finally:
|
||||
# Never block the report on a hung worker; the thread is left to
|
||||
# finish (or hang) on its own — the timeout verdict already shipped.
|
||||
ex.shutdown(wait=False)
|
||||
elapsed = _time.perf_counter() - t0
|
||||
if audio_s <= 0:
|
||||
return _check(
|
||||
"deep_synth", "Deep synthesis", FAIL,
|
||||
f"engine '{engine_id}' returned empty audio in {elapsed:.1f}s",
|
||||
"Synthesis ran but produced no samples - engine output is broken.",
|
||||
)
|
||||
return _check(
|
||||
"deep_synth", "Deep synthesis", OK,
|
||||
f"engine '{engine_id}' produced {audio_s:.1f}s of audio in {elapsed:.1f}s",
|
||||
)
|
||||
|
||||
|
||||
def _check_network() -> dict:
|
||||
# Any HTTP response — even a 4xx — proves the hub is reachable; that's
|
||||
# all model downloads need to get started. urllib honors HTTP(S)_PROXY.
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
req = urllib.request.Request(_HUB_URL, method="HEAD")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=_HUB_TIMEOUT_S):
|
||||
pass
|
||||
return _check("network", "HuggingFace hub", OK, f"{_HUB_URL} reachable")
|
||||
except urllib.error.HTTPError:
|
||||
return _check("network", "HuggingFace hub", OK, f"{_HUB_URL} reachable")
|
||||
except Exception as e:
|
||||
return _check(
|
||||
"network", "HuggingFace hub", WARN,
|
||||
f"{_HUB_URL} unreachable: {e}",
|
||||
"Model downloads will fail until this resolves. Behind a restricted network, set a proxy in Settings > General or configure a mirror via HF_ENDPOINT.",
|
||||
)
|
||||
|
||||
|
||||
def run_diagnostics(include_network: bool = True, deep: bool = False) -> dict:
|
||||
"""Run every check and return the structured report.
|
||||
|
||||
``include_network=False`` skips the hub probe — used by tests and by
|
||||
callers that need the report to come back instantly offline.
|
||||
``deep=True`` additionally loads the active engine and synthesizes a
|
||||
short utterance (may take minutes on a cold install — opt-in only).
|
||||
"""
|
||||
checks = [
|
||||
_check_python(),
|
||||
_check_device(),
|
||||
_check_ffmpeg(),
|
||||
_check_hf_token(),
|
||||
_check_disk(),
|
||||
_check_data_dir(),
|
||||
_check_ram(),
|
||||
_check_engines(),
|
||||
]
|
||||
if include_network:
|
||||
checks.append(_check_network())
|
||||
if deep:
|
||||
checks.append(_check_deep_synthesis())
|
||||
|
||||
counts = {OK: 0, WARN: 0, FAIL: 0}
|
||||
for c in checks:
|
||||
counts[c["status"]] += 1
|
||||
return {
|
||||
"app_version": APP_VERSION,
|
||||
"platform": scrub_text(platform.platform()),
|
||||
"checks": checks,
|
||||
"summary": {
|
||||
"ok": counts[FAIL] == 0,
|
||||
"passed": counts[OK],
|
||||
"warnings": counts[WARN],
|
||||
"failures": counts[FAIL],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def format_text(report: dict) -> str:
|
||||
"""Human-readable rendering for `--diagnose` / pasting into an issue.
|
||||
|
||||
ASCII-only on purpose — Windows consoles with legacy code pages must
|
||||
not choke on the output.
|
||||
"""
|
||||
tag = {OK: "[ OK ]", WARN: "[WARN]", FAIL: "[FAIL]"}
|
||||
lines = [
|
||||
f"OmniVoice Studio self-check - v{report['app_version']} on {report['platform']}",
|
||||
"",
|
||||
]
|
||||
for c in report["checks"]:
|
||||
lines.append(f"{tag[c['status']]} {c['label']}: {c['detail']}")
|
||||
if c.get("hint"):
|
||||
lines.append(f" hint: {c['hint']}")
|
||||
s = report["summary"]
|
||||
lines.append("")
|
||||
lines.append(
|
||||
f"{s['passed']} ok, {s['warnings']} warning(s), {s['failures']} failure(s) - "
|
||||
+ ("looks healthy" if s["ok"] else "needs attention")
|
||||
)
|
||||
return "\n".join(lines)
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Diagnostic bundle — everything a maintainer needs, in one drag-and-drop.
|
||||
|
||||
The prefilled GitHub Issues URL caps out around 8k characters, so logs can
|
||||
never ride along with a report. This module zips the full picture instead:
|
||||
|
||||
omnivoice-diagnostics-<timestamp>.zip
|
||||
├── meta.json app version, platform, python, generated-at
|
||||
├── self_check.txt human-readable diagnose report
|
||||
├── self_check.json same, structured
|
||||
├── errors.json recent error journal (deduped, classified)
|
||||
└── logs/
|
||||
├── omnivoice.log.txt last 500 lines, scrubbed
|
||||
└── crash_log.txt last 200 lines, scrubbed
|
||||
|
||||
Settings → About → "Save diagnostic bundle" builds it and reveals the file;
|
||||
the user drags it onto their GitHub issue. Every text member is passed
|
||||
through core.scrub — the bundle is built TO leave the machine, so it must
|
||||
be safe by construction. The zip is written to OUTPUTS_DIR (user-visible,
|
||||
already revealed-in-folder elsewhere in the app).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import sys
|
||||
import time
|
||||
import zipfile
|
||||
|
||||
from core.config import OUTPUTS_DIR, LOG_PATH, CRASH_LOG_PATH
|
||||
from core.scrub import scrub_text
|
||||
from core.version import APP_VERSION
|
||||
|
||||
_LOG_TAIL_LINES = 500
|
||||
_CRASH_TAIL_LINES = 200
|
||||
|
||||
|
||||
def _scrubbed_tail(path: str, max_lines: int) -> str:
|
||||
"""Last `max_lines` of `path`, scrubbed. Missing/unreadable file → a
|
||||
one-line note instead of a hard failure (the bundle must always build)."""
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
||||
lines = f.readlines()
|
||||
except FileNotFoundError:
|
||||
return f"(no file at {scrub_text(path)})\n"
|
||||
except Exception as e:
|
||||
return f"(could not read {scrub_text(path)}: {scrub_text(str(e))})\n"
|
||||
return scrub_text("".join(lines[-max_lines:]))
|
||||
|
||||
|
||||
def build_bundle(include_network: bool = False) -> str:
|
||||
"""Build the zip and return its absolute path.
|
||||
|
||||
``include_network=False`` by default: the bundle is usually requested
|
||||
exactly when something is wrong, and a hung hub probe shouldn't add 5s
|
||||
to "save the evidence".
|
||||
"""
|
||||
from core.diagnose import run_diagnostics, format_text
|
||||
from core import error_journal
|
||||
|
||||
report = run_diagnostics(include_network=include_network)
|
||||
|
||||
meta = {
|
||||
"app_version": APP_VERSION,
|
||||
"platform": scrub_text(platform.platform()),
|
||||
"python": sys.version.split()[0],
|
||||
"generated_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
||||
}
|
||||
|
||||
stamp = time.strftime("%Y%m%d-%H%M%S")
|
||||
os.makedirs(OUTPUTS_DIR, exist_ok=True)
|
||||
out_path = os.path.join(OUTPUTS_DIR, f"omnivoice-diagnostics-{stamp}.zip")
|
||||
|
||||
with zipfile.ZipFile(out_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||
zf.writestr("meta.json", json.dumps(meta, indent=2, ensure_ascii=False))
|
||||
zf.writestr("self_check.txt", format_text(report))
|
||||
zf.writestr("self_check.json", json.dumps(report, indent=2, ensure_ascii=False))
|
||||
zf.writestr(
|
||||
"errors.json",
|
||||
json.dumps(error_journal.recent(50), indent=2, ensure_ascii=False),
|
||||
)
|
||||
zf.writestr("logs/omnivoice.log.txt", _scrubbed_tail(LOG_PATH, _LOG_TAIL_LINES))
|
||||
zf.writestr("logs/crash_log.txt", _scrubbed_tail(CRASH_LOG_PATH, _CRASH_TAIL_LINES))
|
||||
|
||||
return out_path
|
||||
@@ -0,0 +1,206 @@
|
||||
"""Ring journal of recent backend errors — the "what just broke" store.
|
||||
|
||||
The global exception handler (main.py) records every unhandled exception
|
||||
here. Unlike crash_log.txt (append-only plain text for humans), the journal
|
||||
is structured and deduplicated, so the UI and the bug-report pipeline can
|
||||
answer:
|
||||
|
||||
- what was the most recent backend error? (auto-attach to a report)
|
||||
- is it the same error repeating? (count by fingerprint, "x14 since start")
|
||||
- what KIND of failure is it? (error_class — GPU_OOM, HF_AUTH_FAILED, …)
|
||||
|
||||
Everything stored is pre-scrubbed (core.scrub) because journal entries feed
|
||||
the diagnostic bundle and prefilled GitHub issues. In-memory ring of
|
||||
``_MAX_ENTRIES`` fingerprints, mirrored to ``DATA_DIR/error_journal.jsonl``
|
||||
(rewritten on each record — entry count is small, atomicity beats append
|
||||
here) so the journal survives restarts and the crash it just recorded.
|
||||
|
||||
``error_class`` values: the install-time classes reuse the locked taxonomy
|
||||
keys from core.error_docs_map (HF_AUTH_FAILED, PYANNOTE_LICENSE_REQUIRED) so
|
||||
docs deeplinks keep working; runtime classes (GPU_OOM, DISK_FULL,
|
||||
NETWORK_ERROR, FFMPEG_MISSING) are journal-local and fall back to
|
||||
DEFAULT_DOCS in lookup(). Don't add them to ERROR_DOCS without following
|
||||
the 4-step mirror contract documented there.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
|
||||
from core.config import DATA_DIR
|
||||
from core.scrub import scrub_text
|
||||
|
||||
JOURNAL_PATH = os.path.join(DATA_DIR, "error_journal.jsonl")
|
||||
|
||||
_MAX_ENTRIES = 50
|
||||
_MAX_TRACE_CHARS = 4000
|
||||
|
||||
_lock = threading.Lock()
|
||||
# fingerprint -> entry, oldest first (move_to_end on repeat).
|
||||
_entries: "OrderedDict[str, dict]" = OrderedDict()
|
||||
|
||||
|
||||
# Ordered: first match wins, most specific patterns up top.
|
||||
_CLASS_RULES: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||||
("GPU_OOM", (
|
||||
"cuda out of memory",
|
||||
"mps backend out of memory",
|
||||
"hip out of memory",
|
||||
"out of memory on device",
|
||||
)),
|
||||
("PYANNOTE_LICENSE_REQUIRED", (
|
||||
"pyannote", # only meaningful combined with an auth marker — see classify()
|
||||
)),
|
||||
("HF_AUTH_FAILED", (
|
||||
"401 client error",
|
||||
"403 client error",
|
||||
"gatedrepoerror",
|
||||
"repository not found",
|
||||
"invalid user token",
|
||||
"huggingface_hub.errors",
|
||||
)),
|
||||
("DISK_FULL", (
|
||||
"no space left on device",
|
||||
"errno 28",
|
||||
"disk quota exceeded",
|
||||
)),
|
||||
("FFMPEG_MISSING", (
|
||||
"ffmpeg not found",
|
||||
"ffmpeg is not installed",
|
||||
"no such file or directory: 'ffmpeg'",
|
||||
)),
|
||||
("NETWORK_ERROR", (
|
||||
"connection refused",
|
||||
"connection reset",
|
||||
"connection aborted",
|
||||
"timed out",
|
||||
"timeout",
|
||||
"name or service not known",
|
||||
"temporary failure in name resolution",
|
||||
"ssl",
|
||||
"proxyerror",
|
||||
)),
|
||||
)
|
||||
|
||||
_AUTH_MARKERS = ("401", "403", "gated", "access", "token")
|
||||
|
||||
|
||||
def classify_exception(exc: BaseException, trace: str = "") -> str:
|
||||
"""Best-effort classification of an exception into a stable class key.
|
||||
|
||||
Pattern-matching on message text is inherently fuzzy — the goal is
|
||||
triage ("which docs page / which hint"), not perfection. UNKNOWN is an
|
||||
acceptable answer.
|
||||
"""
|
||||
blob = f"{type(exc).__name__}: {exc}\n{trace}".lower()
|
||||
for cls, needles in _CLASS_RULES:
|
||||
if cls == "PYANNOTE_LICENSE_REQUIRED":
|
||||
# pyannote in the trace alone is too broad (any diarization bug
|
||||
# would match); require an auth/gating marker alongside it.
|
||||
if "pyannote" in blob and any(m in blob for m in _AUTH_MARKERS):
|
||||
return cls
|
||||
continue
|
||||
if any(n in blob for n in needles):
|
||||
return cls
|
||||
return "UNKNOWN"
|
||||
|
||||
|
||||
def _fingerprint(error_class: str, exc: BaseException) -> str:
|
||||
import hashlib
|
||||
raw = f"{error_class}|{type(exc).__name__}|{scrub_text(str(exc))[:200]}"
|
||||
return hashlib.sha1(raw.encode("utf-8", "replace")).hexdigest()[:16]
|
||||
|
||||
|
||||
def _persist_locked() -> None:
|
||||
"""Rewrite the JSONL mirror from the in-memory ring. Caller holds _lock.
|
||||
Never raises — losing persistence must not break the exception handler."""
|
||||
try:
|
||||
tmp = JOURNAL_PATH + ".tmp"
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
for entry in _entries.values():
|
||||
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
||||
os.replace(tmp, JOURNAL_PATH)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _hydrate() -> None:
|
||||
"""Load persisted entries at import so 'recent errors' survives restarts
|
||||
(and shows the error that killed the previous run)."""
|
||||
try:
|
||||
with open(JOURNAL_PATH, encoding="utf-8") as f:
|
||||
for line in f:
|
||||
try:
|
||||
entry = json.loads(line)
|
||||
fp = entry.get("fingerprint")
|
||||
if fp:
|
||||
_entries[fp] = entry
|
||||
except Exception:
|
||||
continue
|
||||
while len(_entries) > _MAX_ENTRIES:
|
||||
_entries.popitem(last=False)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
_hydrate()
|
||||
|
||||
|
||||
def record(exc: BaseException, route: str = "", trace: str = "") -> dict:
|
||||
"""Record an unhandled exception. Returns the (scrubbed) journal entry.
|
||||
|
||||
Never raises — this runs inside the global exception handler, where a
|
||||
second failure would shadow the one being reported.
|
||||
"""
|
||||
try:
|
||||
error_class = classify_exception(exc, trace)
|
||||
fp = _fingerprint(error_class, exc)
|
||||
now = time.strftime("%Y-%m-%dT%H:%M:%S")
|
||||
with _lock:
|
||||
existing = _entries.get(fp)
|
||||
if existing:
|
||||
existing["count"] = int(existing.get("count", 1)) + 1
|
||||
existing["last_seen"] = now
|
||||
existing["route"] = scrub_text(route) or existing.get("route", "")
|
||||
_entries.move_to_end(fp)
|
||||
entry = existing
|
||||
else:
|
||||
entry = {
|
||||
"fingerprint": fp,
|
||||
"error_class": error_class,
|
||||
"type": type(exc).__name__,
|
||||
"message": scrub_text(str(exc)),
|
||||
"route": scrub_text(route),
|
||||
"trace": scrub_text(trace)[:_MAX_TRACE_CHARS],
|
||||
"first_seen": now,
|
||||
"last_seen": now,
|
||||
"count": 1,
|
||||
}
|
||||
_entries[fp] = entry
|
||||
while len(_entries) > _MAX_ENTRIES:
|
||||
_entries.popitem(last=False)
|
||||
_persist_locked()
|
||||
return entry
|
||||
except Exception:
|
||||
return {"error_class": "UNKNOWN", "type": type(exc).__name__, "count": 1}
|
||||
|
||||
|
||||
def recent(limit: int = 20) -> list[dict]:
|
||||
"""Most recent errors first."""
|
||||
with _lock:
|
||||
items = list(_entries.values())
|
||||
return list(reversed(items))[: max(1, min(limit, _MAX_ENTRIES))]
|
||||
|
||||
|
||||
def clear() -> None:
|
||||
with _lock:
|
||||
_entries.clear()
|
||||
try:
|
||||
os.remove(JOURNAL_PATH)
|
||||
except OSError:
|
||||
pass
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Privacy scrubber for diagnostic text that may leave the machine.
|
||||
|
||||
Everything OmniVoice renders into a bug report or diagnostic dump goes
|
||||
through ``scrub_text()`` before it can reach a prefilled GitHub Issues URL
|
||||
(the only outbound path — see CLAUDE.md Capability 2). The scrubber is the
|
||||
backend twin of ``frontend/src/utils/bugReport.js``'s ``scrubText`` and
|
||||
must stay at least as strict:
|
||||
|
||||
- home directories → ``~`` (macOS ``/Users/<name>``, Linux ``/home/<name>``,
|
||||
Windows ``C:\\Users\\<name>``, plus the *actual* ``$HOME`` of this process)
|
||||
- credential-shaped substrings → ``***REDACTED***`` (HF tokens, GitHub
|
||||
PATs, OpenAI-style ``sk-`` keys)
|
||||
- values of env vars whose NAME matches ``*TOKEN*|*KEY*|*SECRET*|
|
||||
*PASSWORD*|*CREDENTIAL*`` — so a stack trace that interpolated a real
|
||||
secret still comes out clean
|
||||
|
||||
Unlike ``core.logging_filter`` (which rewrites log records in-flight and
|
||||
must stay cheap), this module runs on report-sized strings at report time,
|
||||
so it can afford the env-var sweep.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
|
||||
REDACTED = "***REDACTED***"
|
||||
|
||||
# Env-var NAMES whose values must never appear in scrubbed output.
|
||||
_SECRET_NAME_RE = re.compile(r"TOKEN|KEY|SECRET|PASSWORD|CREDENTIAL", re.IGNORECASE)
|
||||
|
||||
# Credential-shaped substrings, independent of where they came from.
|
||||
# Thresholds mirror core.logging_filter: long enough that identifiers like
|
||||
# `hf_hub` or `sk-learn` survive, short enough that real tokens never do.
|
||||
_TOKEN_PATTERNS = (
|
||||
re.compile(r"hf_[A-Za-z0-9]{30,}"), # HuggingFace
|
||||
re.compile(r"github_pat_[A-Za-z0-9_]{20,}"), # GitHub fine-grained PAT
|
||||
re.compile(r"gh[pousr]_[A-Za-z0-9]{30,}"), # GitHub classic tokens
|
||||
re.compile(r"sk-[A-Za-z0-9_\-]{20,}"), # OpenAI-style API keys
|
||||
)
|
||||
|
||||
# Home-directory shapes for all three supported platforms. Matched
|
||||
# pattern-wise (not just this machine's $HOME) so paths quoted from a
|
||||
# user's pasted log on another OS get cleaned too.
|
||||
_HOME_PATTERNS = (
|
||||
re.compile(r"/Users/[^/\s\"']+"), # macOS
|
||||
re.compile(r"/home/[^/\s\"']+"), # Linux
|
||||
re.compile(r"[A-Za-z]:\\Users\\[^\\\s\"']+"), # Windows
|
||||
)
|
||||
|
||||
# Values shorter than this are too entropy-poor to be real secrets and too
|
||||
# likely to shred unrelated text (e.g. PASSWORD_MIN_LENGTH=8 would otherwise
|
||||
# turn every "8" in the report into ***REDACTED***).
|
||||
_MIN_SECRET_LEN = 8
|
||||
|
||||
|
||||
def _env_secret_values() -> list[str]:
|
||||
"""Values of secret-named env vars, longest first so overlapping
|
||||
values (e.g. a token and its prefix) redact cleanly."""
|
||||
vals = [
|
||||
v
|
||||
for k, v in os.environ.items()
|
||||
if _SECRET_NAME_RE.search(k) and v and len(v) >= _MIN_SECRET_LEN
|
||||
]
|
||||
return sorted(vals, key=len, reverse=True)
|
||||
|
||||
|
||||
def scrub_text(text: str | None) -> str:
|
||||
"""Return ``text`` with secrets and home paths redacted.
|
||||
|
||||
Never raises — scrubbing failure must not block a bug report, and a
|
||||
partially-scrubbed string is still better than an unscrubbed one, so
|
||||
each pass is independent.
|
||||
"""
|
||||
if not text:
|
||||
return "" if text is None else str(text)
|
||||
s = str(text)
|
||||
|
||||
# 1. Exact env-var secret values (most specific — run first).
|
||||
try:
|
||||
for val in _env_secret_values():
|
||||
s = s.replace(val, REDACTED)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 2. Credential-shaped substrings.
|
||||
for pat in _TOKEN_PATTERNS:
|
||||
try:
|
||||
s = pat.sub(REDACTED, s)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 3. This process's real home dir (covers symlinked/nonstandard homes
|
||||
# the generic patterns miss), then the per-OS shapes.
|
||||
try:
|
||||
home = os.path.expanduser("~")
|
||||
if home and home not in ("/", "~"):
|
||||
s = s.replace(home, "~")
|
||||
except Exception:
|
||||
pass
|
||||
for pat in _HOME_PATTERNS:
|
||||
try:
|
||||
s = pat.sub("~", s)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return s
|
||||
@@ -12,4 +12,4 @@ from importlib.metadata import PackageNotFoundError, version
|
||||
try:
|
||||
APP_VERSION = version("omnivoice")
|
||||
except PackageNotFoundError: # non-installed source checkout
|
||||
APP_VERSION = "0.3.1"
|
||||
APP_VERSION = "0.3.5"
|
||||
|
||||
+49
-1
@@ -19,6 +19,22 @@ if sys.platform == "win32":
|
||||
os.environ.setdefault("TORCHDYNAMO_DISABLE", "1")
|
||||
os.environ.setdefault("TORCHINDUCTOR_DISABLE", "1")
|
||||
|
||||
# The backend's stdout/stderr are pipes owned by the desktop shell that
|
||||
# spawned it. If that shell exits while the backend survives (crash,
|
||||
# relaunch, orphan), the pipes close — and the next write raises
|
||||
# BrokenPipeError. transformers' tqdm weight-loading bar writes constantly,
|
||||
# so an orphaned backend couldn't load the model at all (caught in the wild
|
||||
# by the in-app diagnostic report). Wrap stdio so EPIPE is swallowed
|
||||
# process-wide: logs are best-effort for a server, model loading is not.
|
||||
# (utils.hf_progress.SafeFileWrapper — same wrapper the patched hub tqdm
|
||||
# already uses for its own fp.)
|
||||
from utils.hf_progress import SafeFileWrapper as _SafeStdio # noqa: E402
|
||||
|
||||
if not getattr(sys.stdout, "_is_safe_wrapper", False):
|
||||
sys.stdout = _SafeStdio(sys.stdout)
|
||||
if not getattr(sys.stderr, "_is_safe_wrapper", False):
|
||||
sys.stderr = _SafeStdio(sys.stderr)
|
||||
|
||||
try:
|
||||
import dotenv
|
||||
|
||||
@@ -482,6 +498,13 @@ async def global_exception_handler(request: Request, exc: Exception):
|
||||
except Exception:
|
||||
logger.exception("Failed to write crash log")
|
||||
logger.exception("Unhandled exception for %s", request.url)
|
||||
# Structured journal entry (dedup + error_class) — feeds /system/errors/
|
||||
# recent, the diagnostic bundle, and the bug-report pipeline. record()
|
||||
# never raises; a journal failure must not shadow the real error.
|
||||
from core import error_journal
|
||||
_entry = error_journal.record(
|
||||
exc, route=str(request.url.path), trace=traceback.format_exc()
|
||||
)
|
||||
# CORSMiddleware doesn't always get a shot at `exception_handler`-created
|
||||
# responses, which leaves the browser reporting every 500 as a bare CORS
|
||||
# error. Attach the headers manually so the real `detail` bubbles up.
|
||||
@@ -491,7 +514,11 @@ async def global_exception_handler(request: Request, exc: Exception):
|
||||
headers["Access-Control-Allow-Origin"] = origin
|
||||
headers["Access-Control-Allow-Credentials"] = "true"
|
||||
headers["Vary"] = "Origin"
|
||||
return JSONResponse({"detail": str(exc)}, status_code=500, headers=headers)
|
||||
return JSONResponse(
|
||||
{"detail": str(exc), "error_class": _entry.get("error_class")},
|
||||
status_code=500,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
|
||||
_LOOPBACK_CLIENTS = {"127.0.0.1", "::1"}
|
||||
@@ -693,8 +720,29 @@ if __name__ == "__main__":
|
||||
help="Boot the server, poll /health, exit 0 on success / 1 on timeout. "
|
||||
"Used by the release-time installer smoke step in .github/workflows/release.yml.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--diagnose",
|
||||
action="store_true",
|
||||
help="Run the self-check suite (device, ffmpeg, HF token, disk, engines, "
|
||||
"network) without starting the server. Exit 0 if healthy, 1 if any "
|
||||
"check fails. Output is scrubbed — safe to paste into a GitHub issue.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--deep",
|
||||
action="store_true",
|
||||
help="With --diagnose: also load the active TTS engine and synthesize a "
|
||||
"short utterance. Catches 'installed but broken'. May cold-load the "
|
||||
"model (minutes + a large download on a fresh install).",
|
||||
)
|
||||
args, _unknown = parser.parse_known_args()
|
||||
|
||||
if args.diagnose:
|
||||
from core.diagnose import run_diagnostics, format_text
|
||||
|
||||
_report = run_diagnostics(deep=args.deep)
|
||||
print(format_text(_report), flush=True)
|
||||
sys.exit(0 if _report["summary"]["ok"] else 1)
|
||||
|
||||
# Single-sourced from OMNIVOICE_PORT so the bare `python main.py` path and
|
||||
# `--health-check` agree with the Rust sidecar / uvicorn-CLI `--port`.
|
||||
_port = network_share.backend_port()
|
||||
|
||||
@@ -577,22 +577,32 @@ class PyTorchWhisperBackend(ASRBackend):
|
||||
def _ensure_pipe(self):
|
||||
if self._pipe is not None:
|
||||
return
|
||||
# Fall back to grabbing the TTS model's ASR head.
|
||||
import asyncio
|
||||
from services.model_manager import get_model
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
if loop.is_running():
|
||||
raise RuntimeError(
|
||||
"PyTorchWhisperBackend needs the ASR pipe — pass it via constructor "
|
||||
"when calling from an async context."
|
||||
)
|
||||
model = loop.run_until_complete(get_model())
|
||||
except RuntimeError:
|
||||
model = asyncio.run(get_model())
|
||||
self._pipe = getattr(model, "_asr_pipe", None)
|
||||
if self._pipe is None:
|
||||
raise RuntimeError("Loaded TTS model has no `_asr_pipe` attribute.")
|
||||
# Build a standalone transformers Whisper pipeline on demand. This runs
|
||||
# on PyTorch's own stack (cuDNN 9 ships with torch), so it works as a
|
||||
# fallback on machines where WhisperX / faster-whisper can't load
|
||||
# cuDNN 8 (the `cudnn_ops_infer64_8.dll` failure, issue #255) — and it
|
||||
# needs neither OMNIVOICE_PRELOAD_TTS_ASR=1 nor a loaded TTS model.
|
||||
# When the TTS model already has an ASR head, dub_core passes it via the
|
||||
# constructor and this path is skipped.
|
||||
import torch
|
||||
from transformers import pipeline as hf_pipeline
|
||||
from services.model_manager import get_best_device
|
||||
|
||||
model_name = os.environ.get(
|
||||
"OMNIVOICE_PYTORCH_ASR_MODEL", "openai/whisper-large-v3-turbo"
|
||||
)
|
||||
device = get_best_device()
|
||||
asr_dtype = torch.float16 if str(device).startswith("cuda") else torch.float32
|
||||
logger.info(
|
||||
"PyTorchWhisperBackend: loading standalone ASR pipeline %s on %s",
|
||||
model_name, device,
|
||||
)
|
||||
self._pipe = hf_pipeline(
|
||||
"automatic-speech-recognition",
|
||||
model=model_name,
|
||||
dtype=asr_dtype,
|
||||
device_map=device,
|
||||
)
|
||||
|
||||
def transcribe(self, audio_path: str, *, word_timestamps: bool = True) -> dict:
|
||||
import soundfile as sf
|
||||
|
||||
@@ -633,6 +633,18 @@ def get_diarization_pipeline(return_error: bool = False):
|
||||
try:
|
||||
torch = _lazy_torch()
|
||||
_ensure_pyannote_hf_token_compat() # #167: use_auth_token -> token
|
||||
# PyTorch 2.6 flipped torch.load's default to weights_only=True, whose
|
||||
# secure unpickler rejects the pyannote checkpoint's metadata globals
|
||||
# (torch_version.TorchVersion, omegaconf nodes, …) — surfacing as
|
||||
# "Weights only load failed / Unsupported global" and breaking
|
||||
# diarization on torch>=2.6 even after the license is accepted (#270).
|
||||
# Reuse the exact allowlist the WhisperX VAD load registers so the
|
||||
# secure load path succeeds; it is idempotent and per-process.
|
||||
try:
|
||||
from services.asr_backend import WhisperXBackend
|
||||
WhisperXBackend._allow_vad_pickle_globals()
|
||||
except Exception as _glob_e:
|
||||
logger.debug("pyannote safe-globals allowlist skipped: %s", _glob_e)
|
||||
from pyannote.audio import Pipeline
|
||||
logger.info("Loading Pyannote Diarization Pipeline...")
|
||||
_diar_pipeline = Pipeline.from_pretrained("pyannote/speaker-diarization-3.1", use_auth_token=hf_token)
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
},
|
||||
"frontend": {
|
||||
"name": "omnivoice-studio",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.5",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@fontsource-variable/source-serif-4": "^5.2.9",
|
||||
@@ -67,6 +67,7 @@
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.6.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"playwright-core": "1.60.0",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.10",
|
||||
"vitest": "^4.1.5",
|
||||
|
||||
@@ -29,6 +29,13 @@ ENV HF_HOME=/app/omnivoice_data/huggingface
|
||||
# Allow bare imports (from core.config, from services.*, etc.) when
|
||||
# uvicorn is started as `backend.main:app` from WORKDIR /app.
|
||||
ENV PYTHONPATH=/app/backend
|
||||
# Headless server deployment: relax the desktop-only loopback origin gate.
|
||||
# Docker's network NAT rewrites the client host to the bridge gateway, so the
|
||||
# gate would otherwise 403 the operator out of /system/* and /api/settings/*
|
||||
# ("Loopback origin required", issue #261). Exposure is governed by the
|
||||
# operator's `-p` port mapping plus the optional share PIN. Desktop builds
|
||||
# never set this, so their loopback boundary is unchanged.
|
||||
ENV OMNIVOICE_SERVER_MODE=1
|
||||
|
||||
# Install system dependencies (FFmpeg is critical for torchaudio/scene splitting)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
|
||||
@@ -46,6 +46,12 @@ services:
|
||||
# OMNIVOICE_BIND_HOST=0.0.0.0 here only opens the container's own
|
||||
# interface. The backend default is 127.0.0.1 (see backend/main.py).
|
||||
- OMNIVOICE_BIND_HOST=0.0.0.0
|
||||
# Headless server: relax the desktop-only loopback origin gate so the
|
||||
# web UI's /system/* and /api/settings/* routes work through Docker's
|
||||
# NAT (issue #261). Already baked into the image; shown here so it's
|
||||
# discoverable. If you front the container with your own auth proxy on
|
||||
# loopback, set this to 0 to re-enable the strict gate.
|
||||
- OMNIVOICE_SERVER_MODE=1
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-sf", "http://localhost:3900/health"]
|
||||
interval: 30s
|
||||
@@ -76,6 +82,9 @@ services:
|
||||
# service above. The host-side `127.0.0.1:3900:3900` mapping keeps
|
||||
# LAN reachability off by default.
|
||||
- OMNIVOICE_BIND_HOST=0.0.0.0
|
||||
# See the CPU service above — relaxes the loopback origin gate for the
|
||||
# headless Docker deployment (issue #261). Set to 0 to re-enable it.
|
||||
- OMNIVOICE_SERVER_MODE=1
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-sf", "http://localhost:3900/health"]
|
||||
interval: 30s
|
||||
|
||||
@@ -118,6 +118,15 @@ Two paths are worth persisting across container restarts:
|
||||
The running version is now shown in **Settings → About → Version** (read live
|
||||
from the backend), so the web UI no longer displays a dash in Docker.
|
||||
- **Checking which version is running:** `docker exec omnivoice python -c "import importlib.metadata; print(importlib.metadata.version('omnivoice'))"`, or hit the `/health` endpoint — it returns `{"status": "ok", "device": ..., "version": "0.3.x"}`.
|
||||
- **"Loopback origin required" errors (and a blank version):** the desktop
|
||||
build restricts the `/system/*` and `/api/settings/*` routes to a loopback
|
||||
origin, but Docker's NAT makes every request look non-loopback, so the gate
|
||||
used to 403 the whole admin UI (issue #261). The image now ships with
|
||||
`OMNIVOICE_SERVER_MODE=1`, which relaxes that gate for the headless
|
||||
deployment — exposure is instead governed by your `-p` port mapping (keep the
|
||||
`127.0.0.1:` prefix to stay local) plus the optional share PIN. If you front
|
||||
the container with your own auth proxy on loopback, set `OMNIVOICE_SERVER_MODE=0`
|
||||
to re-enable the strict gate.
|
||||
- **Media-preview 404 in LAN mode:** see the [LAN access](#lan-access) section
|
||||
above — the `window.location.host` fix shipped in v0.3.
|
||||
- **GPU not detected:** verify `docker run --rm --gpus all nvidia/cuda:12.8.0-base-ubuntu22.04 nvidia-smi` succeeds first.
|
||||
|
||||
@@ -4,6 +4,32 @@ The top 10 errors users have actually hit on `v0.2.x`, with their causes and
|
||||
fixes. Most have a deeplink anchor that the in-app error UI's "Open docs for
|
||||
this error" button targets directly.
|
||||
|
||||
## Start here: self-diagnosis
|
||||
|
||||
<a id="self-diagnosis"></a>
|
||||
|
||||
Before digging through the entries below, let the app diagnose itself:
|
||||
|
||||
- **In the app:** **Settings → About → "Run self-check"** verifies your
|
||||
compute device (CUDA/MPS/CPU), ffmpeg, HuggingFace token, disk space,
|
||||
data-directory permissions, RAM, installed TTS engines, and hub
|
||||
reachability — each with a hint when something's off.
|
||||
- **Headless / terminal:**
|
||||
|
||||
```bash
|
||||
uv run python backend/main.py --diagnose # same checks, exits 1 on failure
|
||||
uv run python backend/main.py --diagnose --deep # also loads the active engine
|
||||
# and synthesizes a test utterance
|
||||
```
|
||||
|
||||
`--deep` catches "installed but broken" engines. On a fresh install it may
|
||||
cold-load the model (minutes, plus a large download).
|
||||
|
||||
- **Filing an issue?** **Settings → About → "Save diagnostic bundle"**
|
||||
produces a zip (self-check report, recent classified errors, scrubbed log
|
||||
tails) you can drag straight onto the GitHub issue. Home paths and
|
||||
anything token-shaped are redacted before they leave your machine.
|
||||
|
||||
## 1. `pkg_resources` missing (ModuleNotFoundError)
|
||||
|
||||
<a id="pkg_resources-missing"></a>
|
||||
@@ -121,7 +147,24 @@ falling back to faster-whisper`.
|
||||
path and is still fast. If you want the latest CT2 wheels, run `uv sync`
|
||||
from a fresh source checkout.
|
||||
|
||||
## 10. IndexTTS / CosyVoice / ChatterboxTTS clash
|
||||
## 10. Windows: `Could not locate cudnn_ops_infer64_8.dll` during transcription
|
||||
|
||||
**Symptom:** on Windows + NVIDIA, transcription/dubbing fails and the backend
|
||||
log shows `Could not locate cudnn_ops_infer64_8.dll`. Settings → Models shows
|
||||
WhisperX or faster-whisper selected.
|
||||
|
||||
**Cause:** WhisperX and faster-whisper run on **CTranslate2**, which needs
|
||||
**cuDNN 8**, but PyTorch 2.8 ships cuDNN 9. OmniVoice side-loads a cuDNN-8 copy
|
||||
from `.venv\Lib\site-packages\cudnn8_compat\`; if that folder is missing
|
||||
(some upgrade paths don't install it), CTranslate2 can't find the DLL.
|
||||
|
||||
**Fix:** switch the ASR backend to **PyTorch Whisper** in **Settings → Models**.
|
||||
It runs on PyTorch's own stack (cuDNN 9, bundled with torch) and needs no
|
||||
cuDNN-8 DLL — it loads its Whisper pipeline on demand (no extra env var). To
|
||||
keep using faster-whisper/WhisperX instead, reinstall to restore the bundled
|
||||
`cudnn8_compat` libraries.
|
||||
|
||||
## 11. IndexTTS / CosyVoice / ChatterboxTTS clash
|
||||
|
||||
**Symptom:** installing one of these engines breaks the others — e.g. after
|
||||
installing CosyVoice, IndexTTS errors out with import conflicts.
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"name": "omnivoice-studio",
|
||||
"private": true,
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.5",
|
||||
"license": "AGPL-3.0-only",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -68,6 +69,7 @@
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.6.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"playwright-core": "1.60.0",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.10",
|
||||
"vitest": "^4.1.5"
|
||||
|
||||
Generated
+13
-2
@@ -1297,6 +1297,16 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs4"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8640e34b88f7652208ce9e88b1a37a2ae95227d84abec377ccd3c5cfeb141ed4"
|
||||
dependencies = [
|
||||
"rustix",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs_extra"
|
||||
version = "1.3.0"
|
||||
@@ -2878,10 +2888,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "omnivoice-studio"
|
||||
version = "0.3.1"
|
||||
version = "0.3.5"
|
||||
dependencies = [
|
||||
"dirs-next",
|
||||
"enigo",
|
||||
"fs4",
|
||||
"libc",
|
||||
"log",
|
||||
"reqwest",
|
||||
@@ -3391,7 +3402,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
[package]
|
||||
name = "omnivoice-studio"
|
||||
version = "0.3.1"
|
||||
version = "0.3.5"
|
||||
description = "OmniVoice Studio – AI voice cloning & dubbing desktop app"
|
||||
authors = ["Debpalash"]
|
||||
license = "AGPL-3.0"
|
||||
license = "AGPL-3.0-only"
|
||||
repository = ""
|
||||
edition = "2021"
|
||||
rust-version = "1.77.2"
|
||||
@@ -46,6 +46,9 @@ reqwest = { version = "0.13", features = ["json"] }
|
||||
sysinfo = { version = "0.33", default-features = false, features = ["system"] }
|
||||
# hf_cache_scan: walk HF cache directory 3-5× faster than Python
|
||||
walkdir = "2"
|
||||
# First-run setup screen: per-path free-disk-space probe (statvfs /
|
||||
# GetDiskFreeSpaceExW) for the minimum-storage install gate
|
||||
fs4 = "0.13"
|
||||
# Cross-platform home/config directories for pill autostart registration
|
||||
dirs-next = "2"
|
||||
|
||||
|
||||
@@ -239,12 +239,28 @@ pub fn spawn_backend<R: tauri::Runtime>(app: &tauri::AppHandle<R>, progress: Opt
|
||||
env.push(("HF_HUB_DISABLE_SYMLINKS_WARNING".into(), "1".into()));
|
||||
env.push(("HF_HUB_DISABLE_SYMLINKS".into(), "1".into()));
|
||||
}
|
||||
// HF endpoint precedence: process env (power user) > setup-screen custom
|
||||
// mirror > region preset.
|
||||
let cfg = load_config(app);
|
||||
if let Ok(hf_ep) = std::env::var("HF_ENDPOINT") {
|
||||
env.push(("HF_ENDPOINT".into(), hf_ep));
|
||||
} else {
|
||||
let cfg = load_config(app);
|
||||
if cfg.region == "china" {
|
||||
env.push(("HF_ENDPOINT".into(), "https://hf-mirror.com".into()));
|
||||
} else if let Some(hf_mirror) = cfg.mirrors.hf_endpoint.as_deref() {
|
||||
env.push(("HF_ENDPOINT".into(), hf_mirror.into()));
|
||||
} else if cfg.region == "china" {
|
||||
env.push(("HF_ENDPOINT".into(), "https://hf-mirror.com".into()));
|
||||
}
|
||||
// Storage layout chosen on the setup screen. Unset (None) means platform
|
||||
// default — we deliberately don't set the env vars then, so legacy
|
||||
// installs keep byte-identical behavior. Process env still wins so a
|
||||
// power user can relocate per-launch.
|
||||
if std::env::var("OMNIVOICE_DATA_DIR").is_err() {
|
||||
if let Some(data_dir) = crate::setup::resolved_data_dir(app) {
|
||||
env.push(("OMNIVOICE_DATA_DIR".into(), data_dir.to_string_lossy().into()));
|
||||
}
|
||||
}
|
||||
if std::env::var("OMNIVOICE_CACHE_DIR").is_err() {
|
||||
if let Some(models_dir) = crate::setup::resolved_models_dir(app) {
|
||||
env.push(("OMNIVOICE_CACHE_DIR".into(), models_dir.to_string_lossy().into()));
|
||||
}
|
||||
}
|
||||
let app_data = app.path().app_local_data_dir().unwrap_or_default();
|
||||
|
||||
@@ -19,6 +19,11 @@ use crate::{BackendState, backend_port};
|
||||
#[derive(Clone, Serialize, Debug)]
|
||||
#[serde(tag = "stage", rename_all = "snake_case")]
|
||||
pub enum BootstrapStage {
|
||||
/// First run with nothing installed: parked on the setup screen waiting
|
||||
/// for the user to confirm an install plan (mode, storage, mirrors).
|
||||
/// Nothing downloads or installs in this stage — `complete_setup` is the
|
||||
/// only way out of it.
|
||||
AwaitingSetup,
|
||||
/// Working out whether we need to bootstrap at all.
|
||||
Checking,
|
||||
/// Fetching the standalone `uv` binary from astral-sh/uv releases.
|
||||
@@ -196,12 +201,12 @@ pub fn retry_bootstrap(app: tauri::AppHandle, state: tauri::State<'_, BootstrapS
|
||||
|
||||
#[tauri::command]
|
||||
pub fn clean_and_retry_bootstrap(app: tauri::AppHandle, state: tauri::State<'_, BootstrapState>) {
|
||||
if let Ok(data_dir) = app.path().app_local_data_dir() {
|
||||
let project_dir = data_dir.join("project");
|
||||
if project_dir.is_dir() {
|
||||
log::info!("Clean retry: removing {}", project_dir.display());
|
||||
let _ = fs::remove_dir_all(&project_dir);
|
||||
}
|
||||
// env_root honors the setup-screen choice (portable / custom env dir), so
|
||||
// clean-retry removes the venv the bootstrap actually uses.
|
||||
let project_dir = crate::setup::env_root(&app).join("project");
|
||||
if project_dir.is_dir() {
|
||||
log::info!("Clean retry: removing {}", project_dir.display());
|
||||
let _ = fs::remove_dir_all(&project_dir);
|
||||
}
|
||||
// Kill any zombie backend still occupying the port from the deleted
|
||||
// project dir, otherwise bootstrap will "attach" to the stale process.
|
||||
@@ -320,11 +325,14 @@ fn rocm_torch_reinstall_args(rocm_index_url: &str) -> Vec<String> {
|
||||
]
|
||||
}
|
||||
|
||||
/// Whether the user opted into the AMD ROCm torch build via
|
||||
/// OMNIVOICE_TORCH_VARIANT=rocm. Default (unset/other) → false (CUDA/CPU path
|
||||
/// unchanged). Returns the ROCm wheel index to use when enabled.
|
||||
fn rocm_opt_in() -> Option<String> {
|
||||
let variant = std::env::var("OMNIVOICE_TORCH_VARIANT").ok()?;
|
||||
/// Whether the user opted into the AMD ROCm torch build — via the
|
||||
/// OMNIVOICE_TORCH_VARIANT env var (power users, takes precedence) or the
|
||||
/// setup screen's Compute choice persisted in config (`configured_variant`).
|
||||
/// Default (unset/"auto") → None (CUDA/CPU path unchanged). Returns the ROCm
|
||||
/// wheel index to use when enabled.
|
||||
fn rocm_opt_in(configured_variant: &str) -> Option<String> {
|
||||
let variant = std::env::var("OMNIVOICE_TORCH_VARIANT")
|
||||
.unwrap_or_else(|_| configured_variant.to_string());
|
||||
if !variant.eq_ignore_ascii_case("rocm") {
|
||||
return None;
|
||||
}
|
||||
@@ -355,7 +363,9 @@ pub fn ensure_venv_ready<R: tauri::Runtime>(app: &tauri::AppHandle<R>, progress:
|
||||
}
|
||||
}
|
||||
|
||||
let app_data = app.path().app_local_data_dir().ok()?;
|
||||
// Root chosen on the setup screen: app_local_data_dir by default, the
|
||||
// exe-adjacent folder in portable mode, or a user-picked custom dir.
|
||||
let app_data = crate::setup::env_root(app);
|
||||
let project_dir = app_data.join("project");
|
||||
let venv_dir = project_dir.join(".venv");
|
||||
let venv_py = venv_python_path(&venv_dir);
|
||||
@@ -585,17 +595,26 @@ pub fn ensure_venv_ready<R: tauri::Runtime>(app: &tauri::AppHandle<R>, progress:
|
||||
set_stage(p, BootstrapStage::CreatingVenv);
|
||||
}
|
||||
// plan-03 (#130): mirror cascade + system-Python fallback so first-run
|
||||
// survives a GitHub-blocked network. Try in order: (1) default GitHub host,
|
||||
// survives a GitHub-blocked network. Try in order: (0) the user's custom
|
||||
// mirror from the setup screen, when set, (1) default GitHub host,
|
||||
// (2) gh-proxy mirror, (3) system Python (only if >= 3.11) — each with
|
||||
// longer timeouts/retries. Stop at the first that succeeds.
|
||||
let mut venv_attempts: Vec<(&str, Vec<&str>, Vec<(&str, &str)>)> = vec![
|
||||
("default", vec!["venv", "--python", "3.11", "--managed-python"], vec![]),
|
||||
(
|
||||
"gh-proxy mirror",
|
||||
let user_cfg = crate::config::load_config(app);
|
||||
let custom_mirrors = user_cfg.mirrors.clone();
|
||||
let mut venv_attempts: Vec<(&str, Vec<&str>, Vec<(&str, String)>)> = Vec::new();
|
||||
if let Some(custom_py_mirror) = custom_mirrors.python_downloads.clone() {
|
||||
venv_attempts.push((
|
||||
"custom mirror (setup screen)",
|
||||
vec!["venv", "--python", "3.11", "--managed-python"],
|
||||
vec![("UV_PYTHON_INSTALL_MIRROR", PY_INSTALL_MIRROR)],
|
||||
),
|
||||
];
|
||||
vec![("UV_PYTHON_INSTALL_MIRROR", custom_py_mirror)],
|
||||
));
|
||||
}
|
||||
venv_attempts.push(("default", vec!["venv", "--python", "3.11", "--managed-python"], vec![]));
|
||||
venv_attempts.push((
|
||||
"gh-proxy mirror",
|
||||
vec!["venv", "--python", "3.11", "--managed-python"],
|
||||
vec![("UV_PYTHON_INSTALL_MIRROR", PY_INSTALL_MIRROR.to_string())],
|
||||
));
|
||||
// Always try the system Python as the LAST resort (mirrors blocked too).
|
||||
// No `--python 3.11` pin and no pre-gate: uv's own interpreter discovery is
|
||||
// the authority — with `only-system` + the project's `requires-python =
|
||||
@@ -606,7 +625,7 @@ pub fn ensure_venv_ready<R: tauri::Runtime>(app: &tauri::AppHandle<R>, progress:
|
||||
venv_attempts.push((
|
||||
"system-python",
|
||||
vec!["venv"],
|
||||
vec![("UV_PYTHON_PREFERENCE", "only-system")],
|
||||
vec![("UV_PYTHON_PREFERENCE", "only-system".to_string())],
|
||||
));
|
||||
|
||||
let mut venv_ok = false;
|
||||
@@ -614,7 +633,7 @@ pub fn ensure_venv_ready<R: tauri::Runtime>(app: &tauri::AppHandle<R>, progress:
|
||||
let mut venv_cmd = Command::new(&uv_path);
|
||||
scrub_python_env(&mut venv_cmd); // #144: don't inherit AppImage's bundled Python
|
||||
apply_uv_http_env(&mut venv_cmd);
|
||||
for &(k, v) in envs {
|
||||
for (k, v) in envs {
|
||||
venv_cmd.env(k, v);
|
||||
}
|
||||
venv_cmd.args(args.iter()).current_dir(&project_dir);
|
||||
@@ -647,8 +666,10 @@ pub fn ensure_venv_ready<R: tauri::Runtime>(app: &tauri::AppHandle<R>, progress:
|
||||
.args(["sync", "--no-dev", "--verbose"])
|
||||
.current_dir(&project_dir);
|
||||
}
|
||||
let effective_region = get_effective_region(app);
|
||||
if effective_region == "china" {
|
||||
// PyPI index precedence: explicit setup-screen mirror > region preset.
|
||||
if let Some(pypi) = custom_mirrors.pypi_index.as_deref() {
|
||||
sync_cmd.env("UV_INDEX_URL", pypi);
|
||||
} else if get_effective_region(app) == "china" {
|
||||
sync_cmd.env("UV_INDEX_URL", "https://mirrors.aliyun.com/pypi/simple/");
|
||||
}
|
||||
let sync_status = run_streaming(app, "installing_deps", &mut sync_cmd);
|
||||
@@ -705,8 +726,8 @@ docs/install/troubleshooting.md).",
|
||||
// OMNIVOICE_TORCH_VARIANT=rocm, reinstall torch/torchaudio from the ROCm
|
||||
// wheel index. Non-fatal: a failure keeps the working CUDA/CPU build rather
|
||||
// than breaking first-run. Default (unset) leaves everything unchanged.
|
||||
if let Some(rocm_url) = rocm_opt_in() {
|
||||
log::info!("OMNIVOICE_TORCH_VARIANT=rocm → reinstalling torch from {}", rocm_url);
|
||||
if let Some(rocm_url) = rocm_opt_in(&user_cfg.torch_variant) {
|
||||
log::info!("ROCm torch variant selected → reinstalling torch from {}", rocm_url);
|
||||
let mut rocm_cmd = Command::new(&uv_path);
|
||||
scrub_python_env(&mut rocm_cmd); // #144: don't inherit AppImage's bundled Python
|
||||
apply_uv_http_env(&mut rocm_cmd);
|
||||
@@ -775,21 +796,26 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rocm_opt_in_gates_strictly_on_the_env_var() {
|
||||
fn rocm_opt_in_gates_on_env_var_or_config() {
|
||||
// This test owns OMNIVOICE_TORCH_VARIANT / _INDEX for its duration; no
|
||||
// other test reads them.
|
||||
std::env::remove_var("OMNIVOICE_TORCH_VARIANT");
|
||||
std::env::remove_var("OMNIVOICE_TORCH_INDEX");
|
||||
assert!(rocm_opt_in().is_none(), "unset → no ROCm (default CUDA/CPU path)");
|
||||
assert!(rocm_opt_in("auto").is_none(), "unset+auto → no ROCm (default CUDA/CPU path)");
|
||||
assert_eq!(
|
||||
rocm_opt_in("rocm").as_deref(),
|
||||
Some(ROCM_TORCH_INDEX),
|
||||
"setup-screen config alone opts in"
|
||||
);
|
||||
|
||||
std::env::set_var("OMNIVOICE_TORCH_VARIANT", "cuda");
|
||||
assert!(rocm_opt_in().is_none(), "non-rocm value → no ROCm");
|
||||
assert!(rocm_opt_in("rocm").is_none(), "env var wins over config (explicit non-rocm)");
|
||||
|
||||
std::env::set_var("OMNIVOICE_TORCH_VARIANT", "ROCm");
|
||||
assert_eq!(rocm_opt_in().as_deref(), Some(ROCM_TORCH_INDEX), "case-insensitive opt-in → default index");
|
||||
assert_eq!(rocm_opt_in("auto").as_deref(), Some(ROCM_TORCH_INDEX), "case-insensitive env opt-in → default index");
|
||||
|
||||
std::env::set_var("OMNIVOICE_TORCH_INDEX", "https://example.test/rocm6.3");
|
||||
assert_eq!(rocm_opt_in().as_deref(), Some("https://example.test/rocm6.3"), "index override honored");
|
||||
assert_eq!(rocm_opt_in("auto").as_deref(), Some("https://example.test/rocm6.3"), "index override honored");
|
||||
|
||||
std::env::remove_var("OMNIVOICE_TORCH_VARIANT");
|
||||
std::env::remove_var("OMNIVOICE_TORCH_INDEX");
|
||||
|
||||
@@ -43,12 +43,64 @@ pub struct AppConfig {
|
||||
/// stable on every launch.
|
||||
#[serde(default = "default_update_channel")]
|
||||
pub update_channel: String,
|
||||
/// True once the user has confirmed the first-run setup screen (or an
|
||||
/// existing pre-setup-screen install was detected and silently migrated).
|
||||
/// While false on a machine with no venv, the bootstrap parks in
|
||||
/// `AwaitingSetup` and nothing downloads or installs.
|
||||
#[serde(default)]
|
||||
pub setup_complete: bool,
|
||||
/// "installed" (platform dirs, default) | "portable" (everything lives in
|
||||
/// `OmniVoiceStudio-Data/` next to the executable / AppImage).
|
||||
#[serde(default = "default_install_mode")]
|
||||
pub install_mode: String,
|
||||
/// Custom root for the managed Python env (`<dir>/project/.venv`).
|
||||
/// None → `app_local_data_dir()` (legacy behavior, byte-identical).
|
||||
#[serde(default)]
|
||||
pub env_dir: Option<String>,
|
||||
/// Custom backend data dir (voices/projects/db) → OMNIVOICE_DATA_DIR.
|
||||
/// None → backend platform default (env var not set at all).
|
||||
#[serde(default)]
|
||||
pub data_dir: Option<String>,
|
||||
/// Custom model-cache dir → OMNIVOICE_CACHE_DIR (backend maps to HF_HOME,
|
||||
/// HF_HUB_CACHE, TORCH_HOME). None → library defaults.
|
||||
#[serde(default)]
|
||||
pub models_dir: Option<String>,
|
||||
/// UI locale chosen on the setup screen, mirrored here so the Rust side
|
||||
/// (tray menus, dialogs) can localize in the future. The webview keeps its
|
||||
/// own copy in localStorage; this field is informational.
|
||||
#[serde(default)]
|
||||
pub locale: Option<String>,
|
||||
/// "auto" (CUDA/MPS/CPU autodetect, default) | "rocm" (AMD wheel reinstall
|
||||
/// after sync). Env var OMNIVOICE_TORCH_VARIANT still wins for power users.
|
||||
#[serde(default = "default_torch_variant")]
|
||||
pub torch_variant: String,
|
||||
/// Explicit mirror URLs that take precedence over region presets.
|
||||
#[serde(default)]
|
||||
pub mirrors: MirrorOverrides,
|
||||
}
|
||||
|
||||
/// Per-source mirror overrides from the setup screen's Advanced section.
|
||||
/// Each empty/None field falls back to the region preset for that source.
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MirrorOverrides {
|
||||
/// PyPI simple-index URL → UV_INDEX_URL during `uv sync`.
|
||||
#[serde(default)]
|
||||
pub pypi_index: Option<String>,
|
||||
/// Hugging Face endpoint → HF_ENDPOINT for the backend process.
|
||||
#[serde(default)]
|
||||
pub hf_endpoint: Option<String>,
|
||||
/// python-build-standalone release base → UV_PYTHON_INSTALL_MIRROR.
|
||||
#[serde(default)]
|
||||
pub python_downloads: Option<String>,
|
||||
}
|
||||
|
||||
pub fn default_region() -> String { "auto".into() }
|
||||
pub fn default_dictation_shortcut() -> String { "CmdOrCtrl+Shift+Space".into() }
|
||||
pub fn default_launch_as_widget() -> bool { false }
|
||||
pub fn default_update_channel() -> String { "stable".into() }
|
||||
pub fn default_install_mode() -> String { "installed".into() }
|
||||
pub fn default_torch_variant() -> String { "auto".into() }
|
||||
|
||||
impl Default for AppConfig {
|
||||
fn default() -> Self {
|
||||
@@ -57,12 +109,30 @@ impl Default for AppConfig {
|
||||
dictation_shortcut: default_dictation_shortcut(),
|
||||
launch_as_widget: default_launch_as_widget(),
|
||||
update_channel: default_update_channel(),
|
||||
setup_complete: false,
|
||||
install_mode: default_install_mode(),
|
||||
env_dir: None,
|
||||
data_dir: None,
|
||||
models_dir: None,
|
||||
locale: None,
|
||||
torch_variant: default_torch_variant(),
|
||||
mirrors: MirrorOverrides::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A `config.json` inside the exe-adjacent portable folder marks (and wins
|
||||
/// over) the standard location — so a portable install keeps working when the
|
||||
/// folder is moved to another machine/disk, with zero state left behind.
|
||||
fn portable_config_file() -> Option<PathBuf> {
|
||||
crate::setup::portable_base()
|
||||
.map(|b| b.join("config.json"))
|
||||
.filter(|p| p.is_file())
|
||||
}
|
||||
|
||||
pub fn config_path<R: tauri::Runtime>(app: &tauri::AppHandle<R>) -> Option<PathBuf> {
|
||||
app.path().app_local_data_dir().ok().map(|d: PathBuf| d.join("config.json"))
|
||||
portable_config_file()
|
||||
.or_else(|| app.path().app_local_data_dir().ok().map(|d: PathBuf| d.join("config.json")))
|
||||
}
|
||||
|
||||
pub fn load_config<R: tauri::Runtime>(app: &tauri::AppHandle<R>) -> AppConfig {
|
||||
@@ -87,18 +157,26 @@ pub fn load_config_pre_app() -> AppConfig {
|
||||
const BUNDLE_IDENTIFIER: &str = "com.debpalash.omnivoice-studio";
|
||||
|
||||
fn config_path_pre_app() -> Option<PathBuf> {
|
||||
dirs_next::data_local_dir().map(|d| d.join(BUNDLE_IDENTIFIER).join("config.json"))
|
||||
portable_config_file()
|
||||
.or_else(|| dirs_next::data_local_dir().map(|d| d.join(BUNDLE_IDENTIFIER).join("config.json")))
|
||||
}
|
||||
|
||||
pub fn save_config<R: tauri::Runtime>(app: &tauri::AppHandle<R>, cfg: &AppConfig) {
|
||||
if let Some(p) = config_path(app) {
|
||||
if let Some(parent) = p.parent() {
|
||||
let _ = fs::create_dir_all(parent);
|
||||
}
|
||||
let _ = fs::write(&p, serde_json::to_string_pretty(cfg).unwrap_or_default());
|
||||
let _ = save_config_at(&p, cfg);
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the config to an explicit path (used by `complete_setup` to seed the
|
||||
/// portable folder before `config_path` starts resolving to it).
|
||||
pub fn save_config_at(path: &PathBuf, cfg: &AppConfig) -> Result<(), String> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| format!("mkdir {}: {e}", parent.display()))?;
|
||||
}
|
||||
let body = serde_json::to_string_pretty(cfg).map_err(|e| e.to_string())?;
|
||||
fs::write(path, body).map_err(|e| format!("write {}: {e}", path.display()))
|
||||
}
|
||||
|
||||
// ── Region helpers ────────────────────────────────────────────────────────
|
||||
|
||||
pub const VALID_REGIONS: &[&str] = &["auto", "global", "china", "russia", "restricted"];
|
||||
@@ -164,6 +242,46 @@ pub fn set_region(app: tauri::AppHandle, region: String) -> String {
|
||||
r.to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A config.json written by any pre-setup-screen build must keep parsing
|
||||
/// with all new fields at safe defaults — this is what makes the setup
|
||||
/// gate invisible to existing installs.
|
||||
#[test]
|
||||
fn legacy_config_parses_with_safe_defaults() {
|
||||
let legacy = r#"{"region":"china","dictation_shortcut":"CmdOrCtrl+Shift+Space","launch_as_widget":false,"update_channel":"preview"}"#;
|
||||
let cfg: AppConfig = serde_json::from_str(legacy).expect("legacy config must parse");
|
||||
assert_eq!(cfg.region, "china");
|
||||
assert_eq!(cfg.update_channel, "preview");
|
||||
assert!(!cfg.setup_complete, "legacy installs must default to setup_complete=false (venv detection migrates them)");
|
||||
assert_eq!(cfg.install_mode, "installed");
|
||||
assert_eq!(cfg.env_dir, None);
|
||||
assert_eq!(cfg.data_dir, None);
|
||||
assert_eq!(cfg.models_dir, None);
|
||||
assert_eq!(cfg.torch_variant, "auto");
|
||||
assert!(cfg.mirrors.pypi_index.is_none());
|
||||
assert!(cfg.mirrors.hf_endpoint.is_none());
|
||||
assert!(cfg.mirrors.python_downloads.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_roundtrips_new_fields() {
|
||||
let mut cfg = AppConfig::default();
|
||||
cfg.setup_complete = true;
|
||||
cfg.install_mode = "portable".into();
|
||||
cfg.models_dir = Some("/mnt/big/models".into());
|
||||
cfg.mirrors.hf_endpoint = Some("https://hf-mirror.com".into());
|
||||
let json = serde_json::to_string(&cfg).unwrap();
|
||||
let back: AppConfig = serde_json::from_str(&json).unwrap();
|
||||
assert!(back.setup_complete);
|
||||
assert_eq!(back.install_mode, "portable");
|
||||
assert_eq!(back.models_dir.as_deref(), Some("/mnt/big/models"));
|
||||
assert_eq!(back.mirrors.hf_endpoint.as_deref(), Some("https://hf-mirror.com"));
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_update_channel(app: tauri::AppHandle) -> String {
|
||||
load_config(&app).update_channel
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
//! commands – Tauri IPC commands (sysinfo, logs, HF cache, paste, tray, dictation)
|
||||
|
||||
pub mod config;
|
||||
pub mod setup;
|
||||
pub mod bootstrap;
|
||||
pub mod tools;
|
||||
pub mod backend;
|
||||
@@ -95,6 +96,9 @@ pub fn run() {
|
||||
bootstrap::get_bootstrap_logs,
|
||||
bootstrap::retry_bootstrap,
|
||||
bootstrap::clean_and_retry_bootstrap,
|
||||
setup::get_setup_state,
|
||||
setup::check_install_target,
|
||||
setup::complete_setup,
|
||||
config::get_region,
|
||||
config::set_region,
|
||||
config::get_update_channel,
|
||||
@@ -511,6 +515,14 @@ pub fn run() {
|
||||
set_stage(&stage_handle, BootstrapStage::Ready);
|
||||
return;
|
||||
}
|
||||
// `--setup` re-opens the install-plan screen on demand — it
|
||||
// must win over the attach-to-healthy-backend shortcut, or a
|
||||
// running backend would skip straight past it.
|
||||
if std::env::args().any(|a| a == "--setup") {
|
||||
log::info!("--setup flag — opening the setup screen");
|
||||
set_stage(&stage_handle, BootstrapStage::AwaitingSetup);
|
||||
return;
|
||||
}
|
||||
if backend::backend_healthy(backend_port()) {
|
||||
log::info!(
|
||||
"Port {} already serving OmniVoice backend — attaching",
|
||||
@@ -527,6 +539,16 @@ pub fn run() {
|
||||
backend::kill_orphan_on_port(backend_port());
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
}
|
||||
// First-run gate: never auto-install. With nothing on disk to
|
||||
// attach to, park on the setup screen and wait for the user to
|
||||
// confirm an install plan — `complete_setup` restarts the
|
||||
// bootstrap from there. Existing installs (venv present) are
|
||||
// detected inside is_first_run and migrate straight through.
|
||||
if setup::is_first_run(&app_handle) {
|
||||
log::info!("First run — awaiting setup screen confirmation before installing");
|
||||
set_stage(&stage_handle, BootstrapStage::AwaitingSetup);
|
||||
return;
|
||||
}
|
||||
let child = backend::spawn_backend(&app_handle, Some(&stage_handle));
|
||||
if let Ok(mut guard) = app_handle.state::<BackendState>().process.lock() {
|
||||
*guard = child;
|
||||
|
||||
@@ -0,0 +1,767 @@
|
||||
//! First-run install setup: the pre-bootstrap configuration surface.
|
||||
//!
|
||||
//! Nothing downloads or installs until the user confirms an [`InstallPlan`]
|
||||
//! via `complete_setup`. The module is split into:
|
||||
//! - requirements: minimum-disk constants (measured, with headroom)
|
||||
//! - disk: per-path free-space / writability probing
|
||||
//! - paths: portable-base + platform default dir resolution
|
||||
//! - plan: InstallPlan validation + application
|
||||
//! - commands: the three Tauri IPC entry points
|
||||
//!
|
||||
//! Resolution helpers (`env_root`, `resolved_data_dir`, `resolved_models_dir`)
|
||||
//! are consumed by `bootstrap.rs` / `backend.rs` so the chosen layout is the
|
||||
//! single source of truth for every later spawn.
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::Manager;
|
||||
|
||||
use crate::bootstrap::{set_stage, BootstrapStage, BootstrapState};
|
||||
use crate::config::{self, MirrorOverrides};
|
||||
|
||||
// ── Requirements ──────────────────────────────────────────────────────────
|
||||
|
||||
pub const GIB: u64 = 1024 * 1024 * 1024;
|
||||
|
||||
/// Python environment (venv + torch/whisperx/demucs wheels). Measured at
|
||||
/// 7.8 GiB on Linux x64 CUDA (v0.3.5); rounded up for pip build temp files.
|
||||
pub const REQUIRED_ENV_BYTES: u64 = 9 * GIB;
|
||||
|
||||
/// Default model set (TTS checkpoint + whisper + demucs in the HF cache).
|
||||
/// Measured at 6.1 GiB after a full clone+dub session; headroom for revisions.
|
||||
pub const REQUIRED_MODELS_BYTES: u64 = 7 * GIB;
|
||||
|
||||
/// Voice data, generation outputs, SQLite DB. Grows with use; 1 GiB floor so
|
||||
/// a first session never hits a full disk mid-render.
|
||||
pub const REQUIRED_DATA_BYTES: u64 = GIB;
|
||||
|
||||
/// Folder created next to the executable / AppImage in portable mode. The
|
||||
/// whole install (env + models + voices + config) lives inside it, so moving
|
||||
/// `app + this folder` together relocates the install.
|
||||
pub const PORTABLE_DIR_NAME: &str = "OmniVoiceStudio-Data";
|
||||
|
||||
// ── Disk probing ──────────────────────────────────────────────────────────
|
||||
|
||||
mod disk {
|
||||
use super::*;
|
||||
|
||||
/// The chosen directory usually doesn't exist yet — walk up to the
|
||||
/// nearest ancestor that does, since that's where space/permissions live.
|
||||
pub fn nearest_existing(path: &Path) -> PathBuf {
|
||||
let mut cur = path.to_path_buf();
|
||||
while !cur.exists() {
|
||||
match cur.parent() {
|
||||
Some(p) => cur = p.to_path_buf(),
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
cur
|
||||
}
|
||||
|
||||
pub fn available_bytes(path: &Path) -> Option<u64> {
|
||||
fs4::available_space(nearest_existing(path)).ok()
|
||||
}
|
||||
|
||||
/// Stable identity of the filesystem holding `path`, so requirements for
|
||||
/// dirs that share a disk are summed before comparing against free space.
|
||||
#[cfg(unix)]
|
||||
pub fn fs_key(path: &Path) -> Option<String> {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
fs::metadata(nearest_existing(path)).ok().map(|m| format!("dev:{}", m.dev()))
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
pub fn fs_key(path: &Path) -> Option<String> {
|
||||
// Windows: the drive prefix (`C:\`) identifies the volume.
|
||||
nearest_existing(path)
|
||||
.components()
|
||||
.next()
|
||||
.map(|c| format!("vol:{}", c.as_os_str().to_string_lossy().to_uppercase()))
|
||||
}
|
||||
|
||||
/// Probe writability of the nearest existing ancestor with a real write —
|
||||
/// permission bits lie (ACLs, read-only mounts, translocation), a temp
|
||||
/// file doesn't. Never creates the target dir itself; that only happens
|
||||
/// on `complete_setup`.
|
||||
pub fn writable(path: &Path) -> bool {
|
||||
let base = nearest_existing(path);
|
||||
if !base.is_dir() {
|
||||
return false;
|
||||
}
|
||||
let probe = base.join(format!(".omnivoice-write-test-{}", std::process::id()));
|
||||
match fs::write(&probe, b"ok") {
|
||||
Ok(()) => {
|
||||
let _ = fs::remove_file(&probe);
|
||||
true
|
||||
}
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Path resolution ───────────────────────────────────────────────────────
|
||||
|
||||
/// Directory that would hold a portable install: next to the executable —
|
||||
/// or next to the `.AppImage` file on Linux (the mounted exe path is an
|
||||
/// ephemeral squashfs mount, useless as an anchor).
|
||||
pub fn portable_base() -> Option<PathBuf> {
|
||||
if let Ok(appimage) = std::env::var("APPIMAGE") {
|
||||
return Path::new(&appimage).parent().map(|p| p.join(PORTABLE_DIR_NAME));
|
||||
}
|
||||
let exe = std::env::current_exe().ok()?;
|
||||
let mut anchor = exe.parent()?.to_path_buf();
|
||||
// macOS: step out of `Foo.app/Contents/MacOS` so the data folder sits
|
||||
// beside the .app bundle, not inside it (inside breaks code signing).
|
||||
if let Some(app_bundle) = anchor
|
||||
.ancestors()
|
||||
.find(|a| a.extension().map(|e| e == "app").unwrap_or(false))
|
||||
{
|
||||
anchor = app_bundle.parent()?.to_path_buf();
|
||||
}
|
||||
Some(anchor.join(PORTABLE_DIR_NAME))
|
||||
}
|
||||
|
||||
/// Mirror of `backend/core/config.py::get_app_data_dir()` platform defaults —
|
||||
/// shown in the UI so the user sees concrete paths, never "(default)".
|
||||
pub fn default_data_dir() -> PathBuf {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
dirs_next::home_dir().unwrap_or_default().join("Library/Application Support/OmniVoice")
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
std::env::var("APPDATA")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_default()
|
||||
.join("OmniVoice")
|
||||
}
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
{
|
||||
dirs_next::home_dir().unwrap_or_default().join(".omnivoice")
|
||||
}
|
||||
}
|
||||
|
||||
/// Default HF model cache (mirrors huggingface_hub + the backend's Windows
|
||||
/// MAX_PATH redirect in `backend/core/config.py`).
|
||||
pub fn default_models_dir() -> PathBuf {
|
||||
if let Ok(hf_home) = std::env::var("HF_HOME") {
|
||||
return PathBuf::from(hf_home);
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
std::env::var("LOCALAPPDATA")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_default()
|
||||
.join("OmniVoice")
|
||||
.join("hf_cache")
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
dirs_next::home_dir().unwrap_or_default().join(".cache/huggingface")
|
||||
}
|
||||
}
|
||||
|
||||
/// Root that holds the managed Python project (`<root>/project/.venv`).
|
||||
/// Single source of truth for bootstrap + clean-retry + backend spawn.
|
||||
pub fn env_root<R: tauri::Runtime>(app: &tauri::AppHandle<R>) -> PathBuf {
|
||||
let cfg = config::load_config(app);
|
||||
if cfg.install_mode == "portable" {
|
||||
if let Some(base) = portable_base() {
|
||||
return base.join("env");
|
||||
}
|
||||
}
|
||||
if let Some(dir) = cfg.env_dir.as_deref().filter(|s| !s.is_empty()) {
|
||||
return PathBuf::from(dir);
|
||||
}
|
||||
app.path().app_local_data_dir().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// User-chosen backend data dir (voices/projects/db) → `OMNIVOICE_DATA_DIR`.
|
||||
/// `None` = backend platform default; we deliberately don't set the env var
|
||||
/// then, so legacy installs keep byte-identical behavior.
|
||||
pub fn resolved_data_dir<R: tauri::Runtime>(app: &tauri::AppHandle<R>) -> Option<PathBuf> {
|
||||
let cfg = config::load_config(app);
|
||||
if cfg.install_mode == "portable" {
|
||||
return portable_base().map(|b| b.join("data"));
|
||||
}
|
||||
cfg.data_dir.as_deref().filter(|s| !s.is_empty()).map(PathBuf::from)
|
||||
}
|
||||
|
||||
/// User-chosen model cache dir → `OMNIVOICE_CACHE_DIR` (backend maps it to
|
||||
/// HF_HOME / HF_HUB_CACHE / TORCH_HOME). Same `None` = default contract.
|
||||
pub fn resolved_models_dir<R: tauri::Runtime>(app: &tauri::AppHandle<R>) -> Option<PathBuf> {
|
||||
let cfg = config::load_config(app);
|
||||
if cfg.install_mode == "portable" {
|
||||
return portable_base().map(|b| b.join("data").join("models"));
|
||||
}
|
||||
cfg.models_dir.as_deref().filter(|s| !s.is_empty()).map(PathBuf::from)
|
||||
}
|
||||
|
||||
// ── First-run detection ───────────────────────────────────────────────────
|
||||
|
||||
/// True only when there is nothing to attach to and the user has never
|
||||
/// completed (or implicitly owned) an install:
|
||||
/// - `setup_complete` in config → returning user
|
||||
/// - dev tree with a `.venv` → contributor running from source
|
||||
/// - existing bootstrapped venv → pre-setup-screen install: migrate
|
||||
/// silently (mark complete) instead of re-asking questions whose answers
|
||||
/// are already on disk.
|
||||
pub fn is_first_run<R: tauri::Runtime>(app: &tauri::AppHandle<R>) -> bool {
|
||||
let cfg = config::load_config(app);
|
||||
if cfg.setup_complete {
|
||||
return false;
|
||||
}
|
||||
if let Some(dev_root) = crate::bootstrap::find_dev_project_root() {
|
||||
if crate::bootstrap::venv_python_path(&dev_root.join(".venv")).is_file() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
let existing_venv = crate::bootstrap::venv_python_path(&env_root(app).join("project").join(".venv"));
|
||||
if existing_venv.is_file() {
|
||||
let mut cfg = cfg;
|
||||
cfg.setup_complete = true;
|
||||
config::save_config(app, &cfg);
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
// ── IPC payloads ──────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SetupState {
|
||||
pub first_run: bool,
|
||||
/// "linux" | "macos" | "windows" — lets the UI hide platform-specific
|
||||
/// opt-ins (e.g. the Linux-only ROCm torch variant) per the
|
||||
/// cross-platform parity rule: identical defaults everywhere,
|
||||
/// platform-only choices never shown where they can't work.
|
||||
pub os: &'static str,
|
||||
pub defaults: SetupDefaults,
|
||||
pub portable: PortableSupport,
|
||||
pub requirements: Requirements,
|
||||
pub hardware: HardwareInfo,
|
||||
}
|
||||
|
||||
/// What the machine offers, shown on the Compute card so the accelerator
|
||||
/// choice is informed rather than a guess. Detection is best-effort and
|
||||
/// must never block setup: every probe degrades to None/CPU.
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HardwareInfo {
|
||||
/// Marketing name when detectable ("NVIDIA GeForce RTX 4070 …").
|
||||
pub gpu: Option<String>,
|
||||
/// "cuda" | "rocm" | "mps" | "cpu" — which torch path this maps to.
|
||||
pub kind: String,
|
||||
/// Human OS name: distro PRETTY_NAME on Linux ("CachyOS", "Ubuntu 24.04"),
|
||||
/// "macOS" / "Windows" elsewhere. The install matrix (OS family × distro
|
||||
/// × arch × GPU vendor) is what users file bug reports with — show it.
|
||||
pub os_name: String,
|
||||
/// "x86_64" | "aarch64" | … — Apple Silicon vs Intel mac, ARM Linux
|
||||
/// (Asahi/Jetson) vs x64 all behave differently for wheels.
|
||||
pub arch: &'static str,
|
||||
pub cpu_cores: usize,
|
||||
pub ram_gb: f64,
|
||||
}
|
||||
|
||||
/// Distro-aware OS label. Linux reads /etc/os-release PRETTY_NAME (falls
|
||||
/// back to NAME, then "Linux"); macOS/Windows are just themselves.
|
||||
fn os_pretty_name() -> String {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if let Ok(body) = fs::read_to_string("/etc/os-release") {
|
||||
for key in ["PRETTY_NAME=", "NAME="] {
|
||||
if let Some(line) = body.lines().find(|l| l.starts_with(key)) {
|
||||
let v = line[key.len()..].trim().trim_matches('"');
|
||||
if !v.is_empty() {
|
||||
return v.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"Linux".to_string()
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
"macOS".to_string()
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
"Windows".to_string()
|
||||
}
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
|
||||
{
|
||||
std::env::consts::OS.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_hardware() -> HardwareInfo {
|
||||
use std::process::Command;
|
||||
let cores = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(0);
|
||||
let ram_gb = {
|
||||
let mut sys = sysinfo::System::new();
|
||||
sys.refresh_memory();
|
||||
(sys.total_memory() as f64 / (1024.0 * 1024.0 * 1024.0) * 10.0).round() / 10.0
|
||||
};
|
||||
let os_name = os_pretty_name();
|
||||
let arch = std::env::consts::ARCH;
|
||||
let base = move |gpu: Option<String>, kind: &str| HardwareInfo {
|
||||
gpu,
|
||||
kind: kind.into(),
|
||||
os_name: os_name.clone(),
|
||||
arch,
|
||||
cpu_cores: cores,
|
||||
ram_gb,
|
||||
};
|
||||
|
||||
// NVIDIA: nvidia-smi ships with the driver on Linux + Windows.
|
||||
let mut smi = Command::new("nvidia-smi");
|
||||
smi.args(["--query-gpu=name", "--format=csv,noheader"]);
|
||||
// Windows: a GUI app spawning a console binary flashes a cmd window —
|
||||
// on the very first screen a user ever sees. CREATE_NO_WINDOW stops it.
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
smi.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
|
||||
}
|
||||
if let Ok(out) = smi.output() {
|
||||
if out.status.success() {
|
||||
if let Some(name) = String::from_utf8_lossy(&out.stdout).lines().next() {
|
||||
let name = name.trim();
|
||||
if !name.is_empty() {
|
||||
return base(Some(name.to_string()), "cuda");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apple Silicon → MPS.
|
||||
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
|
||||
{
|
||||
return base(Some("Apple Silicon".into()), "mps");
|
||||
}
|
||||
|
||||
// AMD on Linux: a DRM card with vendor 0x1002 → ROCm candidate. No
|
||||
// marketing name without lspci, so stay generic.
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if let Ok(entries) = fs::read_dir("/sys/class/drm") {
|
||||
for e in entries.flatten() {
|
||||
let vendor = e.path().join("device").join("vendor");
|
||||
if let Ok(v) = fs::read_to_string(&vendor) {
|
||||
if v.trim() == "0x1002" {
|
||||
return base(Some("AMD GPU".into()), "rocm");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
base(None, "cpu")
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SetupDefaults {
|
||||
pub install_mode: String,
|
||||
pub env_dir: String,
|
||||
pub data_dir: String,
|
||||
pub models_dir: String,
|
||||
pub region: String,
|
||||
pub update_channel: String,
|
||||
pub torch_variant: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PortableSupport {
|
||||
pub available: bool,
|
||||
pub base_dir: Option<String>,
|
||||
/// Machine-readable reason when unavailable: "not_writable" | "no_anchor".
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Requirements {
|
||||
pub env_bytes: u64,
|
||||
pub models_bytes: u64,
|
||||
pub data_bytes: u64,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TargetCheck {
|
||||
pub path: String,
|
||||
pub exists: bool,
|
||||
pub writable: bool,
|
||||
pub free_bytes: Option<u64>,
|
||||
pub fs_key: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InstallPlan {
|
||||
pub install_mode: String,
|
||||
#[serde(default)]
|
||||
pub env_dir: Option<String>,
|
||||
#[serde(default)]
|
||||
pub data_dir: Option<String>,
|
||||
#[serde(default)]
|
||||
pub models_dir: Option<String>,
|
||||
#[serde(default)]
|
||||
pub region: Option<String>,
|
||||
#[serde(default)]
|
||||
pub locale: Option<String>,
|
||||
#[serde(default)]
|
||||
pub update_channel: Option<String>,
|
||||
#[serde(default)]
|
||||
pub torch_variant: Option<String>,
|
||||
#[serde(default)]
|
||||
pub mirrors: Option<MirrorOverrides>,
|
||||
}
|
||||
|
||||
// ── Plan validation + application ─────────────────────────────────────────
|
||||
|
||||
fn none_if_default(chosen: &Option<String>, default: &Path) -> Option<String> {
|
||||
chosen
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.filter(|s| Path::new(s) != default)
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
fn valid_mirror(url: &Option<String>) -> Result<Option<String>, String> {
|
||||
match url.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
|
||||
None => Ok(None),
|
||||
Some(u) if u.starts_with("http://") || u.starts_with("https://") => Ok(Some(u.to_string())),
|
||||
Some(u) => Err(format!("Mirror URL must start with http(s):// — got: {u}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// (target dir, bytes required there) for the chosen layout.
|
||||
fn space_targets(plan: &InstallPlan, env_default: &Path) -> Vec<(PathBuf, u64)> {
|
||||
if plan.install_mode == "portable" {
|
||||
// Everything shares one folder → one combined requirement.
|
||||
let base = portable_base().unwrap_or_default();
|
||||
return vec![(base, REQUIRED_ENV_BYTES + REQUIRED_MODELS_BYTES + REQUIRED_DATA_BYTES)];
|
||||
}
|
||||
let dir_of = |s: &Option<String>, d: &Path| {
|
||||
s.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| d.to_path_buf())
|
||||
};
|
||||
vec![
|
||||
(dir_of(&plan.env_dir, env_default), REQUIRED_ENV_BYTES),
|
||||
(dir_of(&plan.data_dir, &default_data_dir()), REQUIRED_DATA_BYTES),
|
||||
(dir_of(&plan.models_dir, &default_models_dir()), REQUIRED_MODELS_BYTES),
|
||||
]
|
||||
}
|
||||
|
||||
/// Authoritative install gate: group targets by filesystem, sum what each
|
||||
/// volume must hold, and refuse the plan when any volume falls short. The UI
|
||||
/// runs the same math for live feedback; this is the backstop that actually
|
||||
/// "won't let install".
|
||||
fn check_space(targets: &[(PathBuf, u64)]) -> Result<(), String> {
|
||||
use std::collections::HashMap;
|
||||
let mut by_fs: HashMap<String, (PathBuf, u64)> = HashMap::new();
|
||||
for (dir, need) in targets {
|
||||
let key = disk::fs_key(dir).unwrap_or_else(|| dir.to_string_lossy().into_owned());
|
||||
let entry = by_fs.entry(key).or_insert_with(|| (dir.clone(), 0));
|
||||
entry.1 += need;
|
||||
}
|
||||
for (dir, need) in by_fs.values() {
|
||||
let free = disk::available_bytes(dir)
|
||||
.ok_or_else(|| format!("Could not determine free space for {}", dir.display()))?;
|
||||
if free < *need {
|
||||
return Err(format!(
|
||||
"Not enough free space on the disk holding {}: needs ~{:.1} GB, only {:.1} GB available.",
|
||||
dir.display(),
|
||||
*need as f64 / GIB as f64,
|
||||
free as f64 / GIB as f64,
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn check_writable(targets: &[(PathBuf, u64)]) -> Result<(), String> {
|
||||
for (dir, _) in targets {
|
||||
if !disk::writable(dir) {
|
||||
return Err(format!("Directory is not writable: {}", dir.display()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Tauri commands ────────────────────────────────────────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_setup_state(app: tauri::AppHandle) -> SetupState {
|
||||
let cfg = config::load_config(&app);
|
||||
let env_default = app.path().app_local_data_dir().unwrap_or_default();
|
||||
|
||||
let portable = match portable_base() {
|
||||
Some(base) if disk::writable(&base) => PortableSupport {
|
||||
available: true,
|
||||
base_dir: Some(base.to_string_lossy().into_owned()),
|
||||
reason: None,
|
||||
},
|
||||
Some(base) => PortableSupport {
|
||||
available: false,
|
||||
base_dir: Some(base.to_string_lossy().into_owned()),
|
||||
reason: Some("not_writable".into()),
|
||||
},
|
||||
None => PortableSupport { available: false, base_dir: None, reason: Some("no_anchor".into()) },
|
||||
};
|
||||
|
||||
SetupState {
|
||||
first_run: is_first_run(&app),
|
||||
os: std::env::consts::OS,
|
||||
defaults: SetupDefaults {
|
||||
install_mode: cfg.install_mode,
|
||||
env_dir: env_default.to_string_lossy().into_owned(),
|
||||
data_dir: default_data_dir().to_string_lossy().into_owned(),
|
||||
models_dir: default_models_dir().to_string_lossy().into_owned(),
|
||||
region: cfg.region,
|
||||
update_channel: cfg.update_channel,
|
||||
torch_variant: cfg.torch_variant,
|
||||
},
|
||||
portable,
|
||||
requirements: Requirements {
|
||||
env_bytes: REQUIRED_ENV_BYTES,
|
||||
models_bytes: REQUIRED_MODELS_BYTES,
|
||||
data_bytes: REQUIRED_DATA_BYTES,
|
||||
},
|
||||
hardware: detect_hardware(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn check_install_target(path: String) -> TargetCheck {
|
||||
let p = PathBuf::from(path.trim());
|
||||
TargetCheck {
|
||||
exists: p.exists(),
|
||||
writable: disk::writable(&p),
|
||||
free_bytes: disk::available_bytes(&p),
|
||||
fs_key: disk::fs_key(&p),
|
||||
path: p.to_string_lossy().into_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate the plan, persist it, then start the (until now deliberately
|
||||
/// parked) bootstrap. Any `Err` keeps the app in `AwaitingSetup` with the
|
||||
/// message surfaced on the setup screen — nothing was installed.
|
||||
#[tauri::command]
|
||||
pub fn complete_setup(
|
||||
app: tauri::AppHandle,
|
||||
state: tauri::State<'_, BootstrapState>,
|
||||
plan: InstallPlan,
|
||||
) -> Result<(), String> {
|
||||
if !matches!(plan.install_mode.as_str(), "installed" | "portable") {
|
||||
return Err(format!("Unknown install mode: {}", plan.install_mode));
|
||||
}
|
||||
if plan.install_mode == "portable" && portable_base().map(|b| disk::writable(&b)) != Some(true) {
|
||||
return Err("Portable mode is unavailable: the folder next to the app is not writable.".into());
|
||||
}
|
||||
|
||||
let mirrors = match &plan.mirrors {
|
||||
None => MirrorOverrides::default(),
|
||||
Some(m) => MirrorOverrides {
|
||||
pypi_index: valid_mirror(&m.pypi_index)?,
|
||||
hf_endpoint: valid_mirror(&m.hf_endpoint)?,
|
||||
python_downloads: valid_mirror(&m.python_downloads)?,
|
||||
},
|
||||
};
|
||||
|
||||
let env_default = app.path().app_local_data_dir().unwrap_or_default();
|
||||
let targets = space_targets(&plan, &env_default);
|
||||
check_writable(&targets)?;
|
||||
check_space(&targets)?;
|
||||
|
||||
let mut cfg = config::load_config(&app);
|
||||
cfg.setup_complete = true;
|
||||
cfg.install_mode = plan.install_mode.clone();
|
||||
cfg.env_dir = none_if_default(&plan.env_dir, &env_default);
|
||||
cfg.data_dir = none_if_default(&plan.data_dir, &default_data_dir());
|
||||
cfg.models_dir = none_if_default(&plan.models_dir, &default_models_dir());
|
||||
cfg.mirrors = mirrors;
|
||||
if let Some(region) = plan.region.as_deref().filter(|r| config::VALID_REGIONS.contains(r)) {
|
||||
cfg.region = region.to_string();
|
||||
}
|
||||
if let Some(channel) = plan.update_channel.as_deref().filter(|c| config::VALID_CHANNELS.contains(c)) {
|
||||
cfg.update_channel = channel.to_string();
|
||||
}
|
||||
if let Some(variant) = plan.torch_variant.as_deref().filter(|v| ["auto", "rocm"].contains(v)) {
|
||||
// ROCm wheels exist for Linux only — clamp anywhere else so a stray
|
||||
// payload can't configure an install that has no wheels to pull.
|
||||
cfg.torch_variant = if variant == "rocm" && !cfg!(target_os = "linux") {
|
||||
"auto".to_string()
|
||||
} else {
|
||||
variant.to_string()
|
||||
};
|
||||
}
|
||||
cfg.locale = plan.locale.clone().filter(|l| !l.is_empty());
|
||||
|
||||
if plan.install_mode == "portable" {
|
||||
// Create the portable folder and seed config.json INSIDE it first, so
|
||||
// `config_path` resolves portable from here on and the whole install
|
||||
// (env + data + config) travels as one folder.
|
||||
let base = portable_base().ok_or("Portable anchor disappeared")?;
|
||||
fs::create_dir_all(&base).map_err(|e| format!("Could not create {}: {e}", base.display()))?;
|
||||
config::save_config_at(&base.join("config.json"), &cfg)?;
|
||||
} else {
|
||||
for (dir, _) in &targets {
|
||||
fs::create_dir_all(dir).map_err(|e| format!("Could not create {}: {e}", dir.display()))?;
|
||||
}
|
||||
}
|
||||
config::save_config(&app, &cfg);
|
||||
|
||||
log::info!(
|
||||
"Setup complete (mode={}, env={}, data={}, models={}) — starting bootstrap",
|
||||
cfg.install_mode,
|
||||
cfg.env_dir.as_deref().unwrap_or("<default>"),
|
||||
cfg.data_dir.as_deref().unwrap_or("<default>"),
|
||||
cfg.models_dir.as_deref().unwrap_or("<default>"),
|
||||
);
|
||||
set_stage(&state.stage, BootstrapStage::Checking);
|
||||
crate::bootstrap::retry_bootstrap(app, state);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn nearest_existing_walks_up_to_a_real_dir() {
|
||||
let tmp = std::env::temp_dir();
|
||||
let ghost = tmp.join("omnivoice-no-such-dir").join("deeper").join("still-deeper");
|
||||
let found = disk::nearest_existing(&ghost);
|
||||
assert!(found.exists(), "must resolve to an existing ancestor");
|
||||
assert!(ghost.starts_with(&found));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn available_bytes_reports_space_for_temp_dir() {
|
||||
let free = disk::available_bytes(&std::env::temp_dir());
|
||||
assert!(free.is_some(), "temp dir must report free space");
|
||||
assert!(free.unwrap() > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fs_key_is_stable_and_groups_same_volume() {
|
||||
let tmp = std::env::temp_dir();
|
||||
let a = disk::fs_key(&tmp);
|
||||
let b = disk::fs_key(&tmp.join("does-not-exist-yet"));
|
||||
assert!(a.is_some());
|
||||
assert_eq!(a, b, "child of the same volume must share the fs key");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writable_accepts_temp_and_rejects_nonsense() {
|
||||
assert!(disk::writable(&std::env::temp_dir().join("new-subdir-not-created")));
|
||||
#[cfg(unix)]
|
||||
assert!(
|
||||
!disk::writable(Path::new("/proc/omnivoice-definitely-not-writable")),
|
||||
"procfs is not writable"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn space_targets_portable_collapses_to_one_combined_requirement() {
|
||||
let plan = InstallPlan {
|
||||
install_mode: "portable".into(),
|
||||
env_dir: None, data_dir: None, models_dir: None,
|
||||
region: None, locale: None, update_channel: None,
|
||||
torch_variant: None, mirrors: None,
|
||||
};
|
||||
let targets = space_targets(&plan, Path::new("/unused"));
|
||||
assert_eq!(targets.len(), 1);
|
||||
assert_eq!(targets[0].1, REQUIRED_ENV_BYTES + REQUIRED_MODELS_BYTES + REQUIRED_DATA_BYTES);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn space_targets_installed_checks_each_location() {
|
||||
let plan = InstallPlan {
|
||||
install_mode: "installed".into(),
|
||||
env_dir: Some("/x/env".into()),
|
||||
data_dir: Some("/y/data".into()),
|
||||
models_dir: None, // default
|
||||
region: None, locale: None, update_channel: None,
|
||||
torch_variant: None, mirrors: None,
|
||||
};
|
||||
let targets = space_targets(&plan, Path::new("/default-env"));
|
||||
assert_eq!(targets.len(), 3);
|
||||
assert_eq!(targets[0], (PathBuf::from("/x/env"), REQUIRED_ENV_BYTES));
|
||||
assert_eq!(targets[1], (PathBuf::from("/y/data"), REQUIRED_DATA_BYTES));
|
||||
assert_eq!(targets[2].1, REQUIRED_MODELS_BYTES);
|
||||
assert_eq!(targets[2].0, default_models_dir());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_space_sums_requirements_sharing_a_volume() {
|
||||
// Both targets resolve to the temp-dir volume; an absurd combined
|
||||
// requirement must fail even when each alone might pass.
|
||||
let tmp = std::env::temp_dir();
|
||||
let huge = 1024 * 1024 * GIB; // 1 EiB — no consumer disk has this
|
||||
let res = check_space(&[(tmp.clone(), huge), (tmp.join("sub"), huge)]);
|
||||
assert!(res.is_err(), "1 EiB×2 on one volume must be rejected");
|
||||
let msg = res.unwrap_err();
|
||||
assert!(msg.contains("Not enough free space"), "msg: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_space_accepts_tiny_requirements() {
|
||||
assert!(check_space(&[(std::env::temp_dir(), 1)]).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirror_validation_requires_http_scheme() {
|
||||
assert_eq!(valid_mirror(&None).unwrap(), None);
|
||||
assert_eq!(valid_mirror(&Some(" ".into())).unwrap(), None);
|
||||
assert_eq!(
|
||||
valid_mirror(&Some("https://hf-mirror.com".into())).unwrap().as_deref(),
|
||||
Some("https://hf-mirror.com")
|
||||
);
|
||||
assert!(valid_mirror(&Some("ftp://nope".into())).is_err());
|
||||
assert!(valid_mirror(&Some("hf-mirror.com".into())).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn none_if_default_strips_defaults_and_blanks() {
|
||||
let d = Path::new("/default/dir");
|
||||
assert_eq!(none_if_default(&None, d), None);
|
||||
assert_eq!(none_if_default(&Some("".into()), d), None);
|
||||
assert_eq!(none_if_default(&Some("/default/dir".into()), d), None);
|
||||
assert_eq!(none_if_default(&Some("/custom".into()), d), Some("/custom".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_hardware_never_panics_and_reports_the_full_matrix() {
|
||||
let hw = detect_hardware();
|
||||
assert!(["cuda", "rocm", "mps", "cpu"].contains(&hw.kind.as_str()), "kind: {}", hw.kind);
|
||||
assert!(hw.ram_gb >= 0.0);
|
||||
assert!(!hw.os_name.is_empty(), "os_name must always resolve (distro or OS family)");
|
||||
assert!(!hw.arch.is_empty(), "arch must always resolve");
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
assert_eq!(hw.arch, "x86_64");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requirements_match_measured_reality() {
|
||||
// Guard against accidental edits: env ≥ measured 7.8 GiB, models ≥
|
||||
// measured 6.1 GiB — shrinking below measurements would let installs
|
||||
// start that are guaranteed to die mid-download.
|
||||
assert!(REQUIRED_ENV_BYTES >= 8 * GIB);
|
||||
assert!(REQUIRED_MODELS_BYTES >= 7 * GIB);
|
||||
assert!(REQUIRED_DATA_BYTES >= GIB / 2);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||
"productName": "OmniVoice Studio",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.5",
|
||||
"identifier": "com.debpalash.omnivoice-studio",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
|
||||
+71
-6
@@ -49,6 +49,8 @@ import useDubWorkflow from './hooks/useDubWorkflow';
|
||||
const LazyFallback = () => <div className="app-lazy-fallback">{i18n.t('app.loading')}</div>;
|
||||
|
||||
import { Toaster, toast } from 'react-hot-toast';
|
||||
import { toastErrorWithReport } from './utils/errorToast';
|
||||
import { addBreadcrumb } from './utils/breadcrumbs';
|
||||
import {
|
||||
POPULAR_LANGS, POPULAR_ISO, TAGS, CATEGORIES, PRESETS, CLONE_MAX_SECONDS,
|
||||
} from './utils/constants';
|
||||
@@ -102,6 +104,9 @@ function App() {
|
||||
}, [locale, theme, font]);
|
||||
const mode = useAppStore(s => s.mode);
|
||||
const setMode = useAppStore(s => s.setMode);
|
||||
// Breadcrumb every view change — mode names are a closed set, so this is
|
||||
// privacy-safe by construction (see utils/breadcrumbs.js).
|
||||
useEffect(() => { addBreadcrumb(`view:${mode}`); }, [mode]);
|
||||
const [navRailSide, setNavRailSide] = useState(() => {
|
||||
try { return localStorage.getItem('omnivoice.navRailSide') || 'left'; } catch { return 'left'; }
|
||||
});
|
||||
@@ -365,6 +370,13 @@ function App() {
|
||||
const [setupNeeded, setSetupNeeded] = useState(false);
|
||||
const [setupChecked, setSetupChecked] = useState(false);
|
||||
useEffect(() => {
|
||||
// Gate the probe on the bootstrap being 'ready' — before that there is
|
||||
// no backend to answer. Probing from mount burned the 30-attempt ceiling
|
||||
// during the setup/installing acts (minutes long on a first run), so the
|
||||
// wizard was silently skipped straight into the studio once the install
|
||||
// finished. Keyed on bootstrapStage: the probe (re)runs the moment the
|
||||
// backend becomes reachable.
|
||||
if (bootstrapStage !== 'ready') return undefined;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const { setupStatus } = await import('./api/setup');
|
||||
@@ -383,7 +395,39 @@ function App() {
|
||||
if (!cancelled) setSetupChecked(true);
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
}, [bootstrapStage]);
|
||||
|
||||
// ── First sound ──
|
||||
// Onboarding should end with the product doing the thing: the moment the
|
||||
// studio mounts after the wizard, generate one short line locally and play
|
||||
// it. Best-effort by design — a first impression must never surface an
|
||||
// error, so every failure path is silent.
|
||||
useEffect(() => {
|
||||
if (!setupChecked || setupNeeded || bootstrapStage !== 'ready') return;
|
||||
let pending = false;
|
||||
try {
|
||||
pending = sessionStorage.getItem('omnivoice.firstSound') === '1';
|
||||
if (pending) sessionStorage.removeItem('omnivoice.firstSound');
|
||||
} catch { /* private mode */ }
|
||||
if (!pending) return;
|
||||
(async () => {
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('text', i18n.t('firstrun.first_sound_text',
|
||||
'Welcome to your studio. Every word you hear was generated on this machine, just now.'));
|
||||
// Functional model prompt (not user-facing copy) — keeps the demo
|
||||
// voice warm without depending on seeded profiles.
|
||||
fd.append('instruct', 'A warm, friendly narrator voice, medium pace');
|
||||
fd.append('num_step', '16');
|
||||
const res = await fetch(`${API}/generate`, { method: 'POST', body: fd });
|
||||
if (!res.ok) return;
|
||||
const blob = await res.blob();
|
||||
await playBlobAudio(blob);
|
||||
toast.success(i18n.t('firstrun.first_sound_done',
|
||||
'That voice? Generated seconds ago, locally. Welcome in.'), { duration: 7000 });
|
||||
} catch { /* silent — see above */ }
|
||||
})();
|
||||
}, [setupChecked, setupNeeded, bootstrapStage]);
|
||||
|
||||
// ── Tauri auto-updater ──
|
||||
// On boot, ask GitHub Releases if a newer build is available. If yes,
|
||||
@@ -506,6 +550,7 @@ function App() {
|
||||
});
|
||||
|
||||
const handleNativeExport = async (e, sourceIdentifier, fallbackName, mode) => {
|
||||
addBreadcrumb('export');
|
||||
if (e) { e.preventDefault(); e.stopPropagation(); }
|
||||
// Browser / Docker web build: there is no Tauri shell, so the native save
|
||||
// dialog is unavailable — invoking it throws "Cannot read properties of
|
||||
@@ -522,7 +567,7 @@ function App() {
|
||||
} catch (err) { console.warn('exportRecord (browser export path) failed:', err); }
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast.error(i18n.t('app.toast_export_failed', { message: err?.message || err }));
|
||||
toastErrorWithReport(i18n.t('app.toast_export_failed', { message: err?.message || err }), err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -537,7 +582,7 @@ function App() {
|
||||
loadExportHistory();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast.error(i18n.t('app.toast_export_failed', { message: err?.message || err }));
|
||||
toastErrorWithReport(i18n.t('app.toast_export_failed', { message: err?.message || err }), err);
|
||||
}
|
||||
};
|
||||
const revealInFolder = async (filePath) => {
|
||||
@@ -765,6 +810,17 @@ function App() {
|
||||
};
|
||||
|
||||
|
||||
// Install-plan screen outranks everything — both on a true first run and
|
||||
// when explicitly requested via `--setup`. Without this, a live backend
|
||||
// answering /setup/status would route straight to the model wizard and the
|
||||
// awaiting_setup stage would never get to render.
|
||||
if (bootstrapStage === 'awaiting_setup') {
|
||||
return (
|
||||
<div style={{ zoom: uiScale }}>
|
||||
<BootstrapSplash stage={bootstrapStage} message={bootstrapMessage} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// First-run gate: if /setup/status says models aren't on disk yet, render
|
||||
// the wizard instead of the main studio. Dismisses itself once the user
|
||||
// completes the download (or clicks "Skip" if they want to limp along).
|
||||
@@ -781,10 +837,13 @@ function App() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (setupNeeded) {
|
||||
if (setupNeeded && bootstrapStage === 'ready') {
|
||||
// Render outside the `app-container` grid so the wizard spans the full
|
||||
// viewport instead of getting squeezed into whatever grid cell the
|
||||
// studio layout reserves for the main content column.
|
||||
// studio layout reserves for the main content column. Gated on the
|
||||
// bootstrap being 'ready': while the stage is still settling (checking /
|
||||
// awaiting_setup racing the first poll), the wizard must not steal the
|
||||
// mount from the install-plan screen.
|
||||
return (
|
||||
<div
|
||||
className="app-wizard-wrap"
|
||||
@@ -805,7 +864,13 @@ function App() {
|
||||
className="app-wizard-dragstrip"
|
||||
/>
|
||||
<Suspense fallback={<LazyFallback />}>
|
||||
<SetupWizard onReady={() => setSetupNeeded(false)} />
|
||||
<SetupWizard onReady={() => {
|
||||
// First-sound handoff: the studio's first act after onboarding is
|
||||
// to speak. sessionStorage (not localStorage) so it never replays
|
||||
// on later launches — only on the run that finished the wizard.
|
||||
try { sessionStorage.setItem('omnivoice.firstSound', '1'); } catch { /* private mode */ }
|
||||
setSetupNeeded(false);
|
||||
}} />
|
||||
</Suspense>
|
||||
<Suspense fallback={null}>
|
||||
<LogsFooter />
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { transcribeStreamUrl } from './dub';
|
||||
|
||||
// #274: the optional speaker-count hint is appended only when it's a positive
|
||||
// integer; otherwise the backend auto-detects.
|
||||
describe('transcribeStreamUrl', () => {
|
||||
it('omits num_speakers when not provided', () => {
|
||||
expect(transcribeStreamUrl('job1')).toMatch(/\/dub\/transcribe-stream\/job1$/);
|
||||
});
|
||||
|
||||
it('omits num_speakers for null / 0 / negative / NaN', () => {
|
||||
for (const v of [null, undefined, 0, -3, NaN] as (number | null | undefined)[]) {
|
||||
expect(transcribeStreamUrl('j', v)).not.toContain('num_speakers');
|
||||
}
|
||||
});
|
||||
|
||||
it('appends a positive integer hint', () => {
|
||||
expect(transcribeStreamUrl('j', 3)).toContain('num_speakers=3');
|
||||
});
|
||||
|
||||
it('floors a fractional hint', () => {
|
||||
expect(transcribeStreamUrl('j', 2.9)).toContain('num_speakers=2');
|
||||
});
|
||||
});
|
||||
@@ -39,8 +39,14 @@ export async function dubIngestUrl(
|
||||
);
|
||||
}
|
||||
|
||||
export function transcribeStreamUrl(jobId: string): string {
|
||||
return `${API}/dub/transcribe-stream/${jobId}`;
|
||||
export function transcribeStreamUrl(jobId: string, numSpeakers?: number | null): string {
|
||||
const base = `${API}/dub/transcribe-stream/${jobId}`;
|
||||
// Optional pyannote speaker-count hint (#274). Only appended when a positive
|
||||
// integer; otherwise the backend auto-detects.
|
||||
if (numSpeakers && Number.isFinite(numSpeakers) && numSpeakers > 0) {
|
||||
return `${base}?num_speakers=${Math.floor(numSpeakers)}`;
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
export async function dubAbort(jobId: string): Promise<void> {
|
||||
|
||||
@@ -60,6 +60,7 @@ export interface SystemInfo {
|
||||
app_version?: string;
|
||||
python?: string;
|
||||
platform?: string;
|
||||
arch?: string;
|
||||
device?: string;
|
||||
data_dir?: string;
|
||||
outputs_dir?: string;
|
||||
|
||||
@@ -1,364 +1,4 @@
|
||||
.bootstrap-splash {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--chrome-bg, #141414);
|
||||
color: var(--chrome-fg, #eee);
|
||||
font-family: 'Inter Variable', 'Inter', system-ui, sans-serif;
|
||||
z-index: 9999;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.bootstrap-splash__card {
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
background: color-mix(in srgb, var(--chrome-fg, #eee) 4%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--chrome-fg, #eee) 10%, transparent);
|
||||
border-radius: 14px;
|
||||
padding: 2rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.bootstrap-splash__title-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.bootstrap-splash__card h1 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.bootstrap-splash__version {
|
||||
font-size: 0.72rem;
|
||||
opacity: 0.45;
|
||||
font-family: 'IBM Plex Mono', ui-monospace, monospace;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.bootstrap-splash__region {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.bootstrap-splash__region-select {
|
||||
background: color-mix(in srgb, var(--chrome-fg, #eee) 8%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--chrome-fg, #eee) 12%, transparent);
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-size: 0.72rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%23999'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.4rem center;
|
||||
padding-right: 1.4rem;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.bootstrap-splash__region-select:hover {
|
||||
background-color: color-mix(in srgb, var(--chrome-fg, #eee) 14%, transparent);
|
||||
}
|
||||
.bootstrap-splash__region-select:focus {
|
||||
outline: 1px solid var(--chrome-accent, #8ec07c);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.bootstrap-splash__region-select option {
|
||||
background: #1a1a1a;
|
||||
color: #eee;
|
||||
}
|
||||
|
||||
.bootstrap-splash__lang {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.bootstrap-splash__lang-select {
|
||||
background: color-mix(in srgb, var(--chrome-fg, #eee) 8%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--chrome-fg, #eee) 12%, transparent);
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-size: 0.72rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%23999'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.4rem center;
|
||||
padding-right: 1.4rem;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.bootstrap-splash__lang-select:hover {
|
||||
background-color: color-mix(in srgb, var(--chrome-fg, #eee) 14%, transparent);
|
||||
}
|
||||
.bootstrap-splash__lang-select:focus {
|
||||
outline: 1px solid var(--chrome-accent, #8ec07c);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.bootstrap-splash__lang-select option {
|
||||
background: #1a1a1a;
|
||||
color: #eee;
|
||||
}
|
||||
|
||||
.bootstrap-splash__suggestion {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: color-mix(in srgb, var(--chrome-accent, #8ec07c) 12%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--chrome-accent, #8ec07c) 20%, transparent);
|
||||
color: #eee;
|
||||
padding: 0.45rem 0.75rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.78rem;
|
||||
margin-top: 0.25rem;
|
||||
margin-bottom: 1.25rem;
|
||||
animation: bootstrapSplashSlideDown 0.2s ease-out;
|
||||
}
|
||||
|
||||
.bootstrap-splash__suggestion-actions {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.bootstrap-splash__suggestion-actions button {
|
||||
background: color-mix(in srgb, var(--chrome-accent, #8ec07c) 20%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--chrome-accent, #8ec07c) 35%, transparent);
|
||||
color: #eee;
|
||||
padding: 0.15rem 0.45rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.72rem;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.bootstrap-splash__suggestion-actions button:hover {
|
||||
background: color-mix(in srgb, var(--chrome-accent, #8ec07c) 35%, transparent);
|
||||
}
|
||||
|
||||
@keyframes bootstrapSplashSlideDown {
|
||||
from { opacity: 0; transform: translateY(-6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.bootstrap-splash__status {
|
||||
margin: 0 0 1.25rem;
|
||||
font-size: 0.95rem;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.bootstrap-splash__bar {
|
||||
height: 4px;
|
||||
width: 100%;
|
||||
border-radius: 3px;
|
||||
background: color-mix(in srgb, var(--chrome-fg, #eee) 8%, transparent);
|
||||
overflow: hidden;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.bootstrap-splash__bar-fill {
|
||||
height: 100%;
|
||||
background: var(--chrome-accent, #8ec07c);
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
|
||||
.bootstrap-splash__steps {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.bootstrap-splash__steps li {
|
||||
padding-left: 1.5rem;
|
||||
position: relative;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.bootstrap-splash__steps li::before {
|
||||
content: '○';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.bootstrap-splash__steps li.done {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.bootstrap-splash__steps li.done::before {
|
||||
content: '✓';
|
||||
color: var(--chrome-accent, #8ec07c);
|
||||
}
|
||||
|
||||
.bootstrap-splash__steps li.active {
|
||||
opacity: 1;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.bootstrap-splash__steps li.active::before {
|
||||
content: '●';
|
||||
color: var(--chrome-accent, #8ec07c);
|
||||
animation: bootstrap-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes bootstrap-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
|
||||
.bootstrap-splash__error {
|
||||
font-family: 'IBM Plex Mono', ui-monospace, monospace;
|
||||
font-size: 0.8rem;
|
||||
background: color-mix(in srgb, #ef4444 12%, transparent);
|
||||
border: 1px solid color-mix(in srgb, #ef4444 35%, transparent);
|
||||
color: #fca5a5;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 8px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 250px;
|
||||
overflow-y: auto;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.bootstrap-splash__sub-progress {
|
||||
margin: -0.5rem 0 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.bootstrap-splash__sub-bar {
|
||||
height: 3px;
|
||||
width: 100%;
|
||||
border-radius: 2px;
|
||||
background: color-mix(in srgb, var(--chrome-fg, #eee) 6%, transparent);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bootstrap-splash__sub-bar-fill {
|
||||
height: 100%;
|
||||
background: color-mix(in srgb, var(--chrome-accent, #8ec07c) 70%, transparent);
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
|
||||
.bootstrap-splash__sub-label {
|
||||
font-family: 'IBM Plex Mono', ui-monospace, monospace;
|
||||
font-size: 0.72rem;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.bootstrap-splash__log-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
.bootstrap-splash__log-toggle {
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
opacity: 0.65;
|
||||
font: inherit;
|
||||
font-size: 0.78rem;
|
||||
padding: 0.25rem 0;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.bootstrap-splash__log-toggle:hover { opacity: 1; }
|
||||
|
||||
.bootstrap-splash__log-count {
|
||||
flex: 1;
|
||||
opacity: 0.45;
|
||||
font-size: 0.72rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.bootstrap-splash__logs {
|
||||
margin: 0.5rem 0 0;
|
||||
max-height: 280px;
|
||||
min-height: 100px;
|
||||
overflow-y: auto;
|
||||
font-family: 'IBM Plex Mono', ui-monospace, monospace;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.45;
|
||||
background: color-mix(in srgb, var(--chrome-fg, #eee) 4%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--chrome-fg, #eee) 8%, transparent);
|
||||
border-radius: 8px;
|
||||
padding: 0.6rem 0.75rem;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
opacity: 0.85;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.bootstrap-splash__copy-btn {
|
||||
margin-top: 0.5rem;
|
||||
background: color-mix(in srgb, var(--chrome-fg, #eee) 8%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--chrome-fg, #eee) 12%, transparent);
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.35rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.bootstrap-splash__copy-btn:hover {
|
||||
background: color-mix(in srgb, var(--chrome-fg, #eee) 14%, transparent);
|
||||
}
|
||||
|
||||
/* ── Error hints + retry actions ── */
|
||||
.bootstrap-splash__hints {
|
||||
margin-top: 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.9;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.bootstrap-splash__hints strong { display: block; margin-bottom: 0.35rem; }
|
||||
.bootstrap-splash__hints ul {
|
||||
margin: 0; padding-left: 1.25rem;
|
||||
display: flex; flex-direction: column; gap: 0.25rem;
|
||||
}
|
||||
.bootstrap-splash__hints li { opacity: 0.85; }
|
||||
|
||||
.bootstrap-splash__actions {
|
||||
display: flex; gap: 0.5rem; margin-top: 1rem;
|
||||
}
|
||||
.bootstrap-splash__retry-btn {
|
||||
flex: 1;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid color-mix(in srgb, var(--chrome-fg, #eee) 15%, transparent);
|
||||
background: color-mix(in srgb, var(--chrome-accent, #8ec07c) 15%, transparent);
|
||||
color: var(--chrome-fg, #eee);
|
||||
font: inherit; font-size: 0.82rem; font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.bootstrap-splash__retry-btn:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--chrome-accent, #8ec07c) 25%, transparent);
|
||||
}
|
||||
.bootstrap-splash__retry-btn:disabled { opacity: 0.5; cursor: wait; }
|
||||
.bootstrap-splash__retry-btn--danger {
|
||||
background: color-mix(in srgb, #ef4444 12%, transparent);
|
||||
border-color: color-mix(in srgb, #ef4444 30%, transparent);
|
||||
}
|
||||
.bootstrap-splash__retry-btn--danger:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, #ef4444 22%, transparent);
|
||||
}
|
||||
/* The bootstrap splash now renders entirely in the shared first-run design
|
||||
* system (frs-* classes in FirstRunSetup.css) so that setup → install →
|
||||
* model wizard reads as one continuous experience. This file intentionally
|
||||
* carries no rules; it remains so stale imports keep resolving. */
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* First-run bootstrap splash.
|
||||
* First-run bootstrap splash — the "installing" act of the first-run journey.
|
||||
*
|
||||
* Two data sources drive this UI:
|
||||
* 1. `bootstrap_status` Tauri command (polled every 1 s) — coarse stage.
|
||||
@@ -7,14 +7,24 @@
|
||||
* from `uv sync`, ffmpeg byte counts, etc. The log panel shows the
|
||||
* last N lines so users can see *something* happening during the 5–10
|
||||
* min dependency install.
|
||||
*
|
||||
* Visual language: the same "studio console" system as FirstRunSetup
|
||||
* (frs-* classes from FirstRunSetup.css) — whisper waveform masthead,
|
||||
* engraved mono section titles, LED step rail, segmented progress meter —
|
||||
* so setup → install → model wizard reads as one continuous experience.
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Suspense, lazy, useEffect, useRef, useState } from 'react';
|
||||
import { copyText } from "../utils/copyText";
|
||||
import './FirstRunSetup.css';
|
||||
import './BootstrapSplash.css';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import i18n, { LANGUAGES } from '../i18n';
|
||||
import { useAppStore } from '../store';
|
||||
|
||||
// First-run only: keep the setup screen out of the main bundle so every
|
||||
// regular launch pays nothing for it.
|
||||
const FirstRunSetup = lazy(() => import('./FirstRunSetup'));
|
||||
|
||||
const getSystemLanguage = () => {
|
||||
if (typeof navigator === 'undefined') return 'en';
|
||||
const navLang = navigator.language || (navigator.languages && navigator.languages[0]) || 'en';
|
||||
@@ -68,6 +78,12 @@ function detectHints(message, logs) {
|
||||
return hints;
|
||||
}
|
||||
|
||||
function formatEta(seconds) {
|
||||
if (!Number.isFinite(seconds) || seconds <= 0) return '';
|
||||
if (seconds < 60) return '<1m';
|
||||
return `${Math.round(seconds / 60)}m`;
|
||||
}
|
||||
|
||||
function formatBytes(n) {
|
||||
if (!n || n < 0) return '';
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
@@ -77,6 +93,26 @@ function formatBytes(n) {
|
||||
return `${v.toFixed(v < 10 ? 1 : 0)} ${units[i]}`;
|
||||
}
|
||||
|
||||
/** Whisper waveform — same speech-cadence silhouette as the setup screen. */
|
||||
function Waveform({ bars = 96 }) {
|
||||
const heights = Array.from({ length: bars }, (_, i) => {
|
||||
const t = i / bars;
|
||||
const v = Math.abs(
|
||||
Math.sin(t * Math.PI * 7.3) * 0.55 +
|
||||
Math.sin(t * Math.PI * 2.1 + 1.2) * 0.3 +
|
||||
Math.sin(t * Math.PI * 17.0 + 0.4) * 0.15
|
||||
);
|
||||
return 0.18 + v * 0.82;
|
||||
});
|
||||
return (
|
||||
<div className="frs-wave" aria-hidden="true">
|
||||
{heights.map((h, i) => (
|
||||
<span key={i} className="frs-wave__bar" style={{ '--h': h, '--d': `${(i * 73) % 1400}ms` }} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BootstrapSplash({ stage, message }) {
|
||||
const { t } = useTranslation();
|
||||
const locale = useAppStore(s => s.locale);
|
||||
@@ -117,6 +153,8 @@ export function BootstrapSplash({ stage, message }) {
|
||||
const [region, setRegionState] = useState('auto');
|
||||
const [retrying, setRetrying] = useState(false);
|
||||
const logRef = useRef(null);
|
||||
const prevProgRef = useRef(null); // {bytes, t} — last progress event
|
||||
const rateRef = useRef(0); // EMA bytes/sec across events
|
||||
|
||||
const handleRetry = async () => {
|
||||
if (retrying) return;
|
||||
@@ -202,7 +240,18 @@ export function BootstrapSplash({ stage, message }) {
|
||||
});
|
||||
});
|
||||
unlistenProgress = await listen('bootstrap-progress', (e) => {
|
||||
setProgress(e.payload || null);
|
||||
const payload = e.payload || null;
|
||||
// EMA byte-rate from successive events → ETA for the long stretch.
|
||||
if (payload?.bytes_done != null) {
|
||||
const now = Date.now();
|
||||
const prev = prevProgRef.current;
|
||||
if (prev && payload.bytes_done > prev.bytes && now > prev.t) {
|
||||
const inst = (payload.bytes_done - prev.bytes) / ((now - prev.t) / 1000);
|
||||
rateRef.current = rateRef.current ? rateRef.current * 0.7 + inst * 0.3 : inst;
|
||||
}
|
||||
prevProgRef.current = { bytes: payload.bytes_done, t: now };
|
||||
}
|
||||
setProgress(payload);
|
||||
});
|
||||
} catch {
|
||||
/* not in Tauri or listen unavailable — silent */
|
||||
@@ -224,7 +273,6 @@ export function BootstrapSplash({ stage, message }) {
|
||||
}, [logs, logsOpen]);
|
||||
|
||||
// Auto-expand logs on failure so users can see + copy the full output.
|
||||
// Also expand on failure (in case user collapsed manually).
|
||||
useEffect(() => {
|
||||
if (isFailed) setLogsOpen(true);
|
||||
}, [isFailed]);
|
||||
@@ -244,134 +292,196 @@ export function BootstrapSplash({ stage, message }) {
|
||||
|
||||
const stageProgress = progress && progress.stage === stage ? progress : null;
|
||||
const pctFromBytes = stageProgress?.percent != null ? stageProgress.percent : null;
|
||||
// Overall journey progress: completed steps + byte-progress within the
|
||||
// current step when the backend reports it.
|
||||
const overallPct = Math.min(
|
||||
100,
|
||||
((stepIndex + (pctFromBytes != null ? pctFromBytes / 100 : 0.4)) / STEPS.length) * 100,
|
||||
);
|
||||
|
||||
// First run with nothing installed: Rust parks in `awaiting_setup` and the
|
||||
// install-plan screen takes over. complete_setup advances the stage, and
|
||||
// the regular progress UI below resumes automatically on the next poll.
|
||||
// (Checked after every hook above so the setup → install transition keeps
|
||||
// the hook order stable.)
|
||||
if (stage === 'awaiting_setup') {
|
||||
return (
|
||||
<Suspense fallback={<div className="frs"><div className="frs__atmo" /></div>}>
|
||||
<FirstRunSetup />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bootstrap-splash">
|
||||
<div className="bootstrap-splash__card">
|
||||
<div className="bootstrap-splash__title-row">
|
||||
<h1>{t('bootstrap.title', 'OmniVoice Studio')}</h1>
|
||||
<span className="bootstrap-splash__version">v{APP_VERSION}</span>
|
||||
<div className="bootstrap-splash__region">
|
||||
<select
|
||||
className="bootstrap-splash__region-select"
|
||||
value={region}
|
||||
onChange={(e) => handleRegionChange(e.target.value)}
|
||||
>
|
||||
<option value="auto">🌐 {t('bootstrap.auto_detect', 'Auto-detect')}</option>
|
||||
<option value="global">🌐 {t('bootstrap.region_global')}</option>
|
||||
<option value="china">🇨🇳 {t('bootstrap.region_china')}</option>
|
||||
<option value="russia">🇷🇺 {t('bootstrap.region_russia')}</option>
|
||||
<option value="restricted">🌍 {t('bootstrap.region_restricted')}</option>
|
||||
</select>
|
||||
<div className="frs">
|
||||
<div className="frs__atmo" aria-hidden="true" />
|
||||
<div className="frs__deck frs__deck--focus">
|
||||
|
||||
{/* ── Masthead: same identity as the setup screen ───────────────── */}
|
||||
<header className="frs__mast frs-rise" style={{ '--rise': 0 }} data-tauri-drag-region>
|
||||
<Waveform />
|
||||
{/* Journey rail: act 2 of the install flow (setup already done). */}
|
||||
<nav className="frs-wsteps frs-wsteps--journey" aria-label={t('bootstrap.title', 'OmniVoice Studio')}>
|
||||
<span className="frs-wstep is-done">
|
||||
<span className="frs-wstep__led" aria-hidden="true" />
|
||||
{t('firstrun.stage_setup', 'Setup')}
|
||||
</span>
|
||||
<span className="frs-wstep is-active">
|
||||
<span className="frs-wstep__led" aria-hidden="true" />
|
||||
{t('firstrun.installing_title', 'Installing')}
|
||||
</span>
|
||||
<span className="frs-wstep">
|
||||
<span className="frs-wstep__led" aria-hidden="true" />
|
||||
{t('firstrun.stage_models', 'Models & engines')}
|
||||
</span>
|
||||
</nav>
|
||||
<div className="frs__mast-row">
|
||||
<div className="frs__mast-text">
|
||||
<h1 className="frs__title">{t('bootstrap.title', 'OmniVoice Studio')}</h1>
|
||||
<p className="frs__subtitle" aria-live="polite">{label}</p>
|
||||
</div>
|
||||
<div className="frs__mast-meta">
|
||||
<div className="frs__mast-selects">
|
||||
<select
|
||||
className="frs-select frs-select--lang"
|
||||
value={locale}
|
||||
onChange={(e) => handleLocaleChange(e.target.value)}
|
||||
aria-label={t('firstrun.language', 'Language')}
|
||||
>
|
||||
{LANGUAGES.map((l) => (
|
||||
<option key={l.code} value={l.code}>{l.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="frs-select frs-select--lang"
|
||||
value={region}
|
||||
onChange={(e) => handleRegionChange(e.target.value)}
|
||||
aria-label={t('firstrun.region_label', 'Download region')}
|
||||
>
|
||||
<option value="auto">🌐 {t('bootstrap.auto_detect', 'Auto-detect')}</option>
|
||||
<option value="global">🌐 {t('bootstrap.region_global')}</option>
|
||||
<option value="china">🇨🇳 {t('bootstrap.region_china')}</option>
|
||||
<option value="russia">🇷🇺 {t('bootstrap.region_russia')}</option>
|
||||
<option value="restricted">🌍 {t('bootstrap.region_restricted')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bootstrap-splash__lang" style={{ marginLeft: '0.5rem' }}>
|
||||
<select
|
||||
className="bootstrap-splash__lang-select"
|
||||
value={locale}
|
||||
onChange={(e) => handleLocaleChange(e.target.value)}
|
||||
>
|
||||
{LANGUAGES.map((l) => (
|
||||
<option key={l.code} value={l.code}>{l.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{showSuggestion && (
|
||||
<div className="bootstrap-splash__suggestion">
|
||||
<div className="frs-banner frs-rise" style={{ '--rise': 1 }}>
|
||||
<span>🌐 {t('bootstrap.suggest_lang', { lang: LANGUAGES.find(l => l.code === systemLang)?.label || systemLang })}</span>
|
||||
<div className="bootstrap-splash__suggestion-actions">
|
||||
<button onClick={acceptSuggestion}>{t('common.yes', 'Yes')}</button>
|
||||
<button onClick={dismissSuggestion}>{t('common.no', 'No')}</button>
|
||||
<div className="frs-banner__actions">
|
||||
<button type="button" className="frs-btn frs-btn--quiet" onClick={acceptSuggestion}>{t('common.yes', 'Yes')}</button>
|
||||
<button type="button" className="frs-btn frs-btn--quiet" onClick={dismissSuggestion}>{t('common.no', 'No')}</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<p className="bootstrap-splash__status">{label}</p>
|
||||
|
||||
{isFailed ? (
|
||||
<>
|
||||
<pre className="bootstrap-splash__error">{message || t('bootstrap.unknown_error')}</pre>
|
||||
<div className="bootstrap-splash__hints">
|
||||
<strong>💡 {t('bootstrap.what_to_try', 'What to try:')}</strong>
|
||||
<section className="frs-panel frs-rise" style={{ '--rise': 1 }}>
|
||||
<h2 className="frs-panel__title">{t('bootstrap.failed', 'Setup failed')}</h2>
|
||||
<pre className="frs__error">{message || t('bootstrap.unknown_error')}</pre>
|
||||
<div className="frs-hints">
|
||||
<span className="frs-hints__label">💡 {t('bootstrap.what_to_try', 'What to try:')}</span>
|
||||
<ul>
|
||||
{detectHints(message, logs).map((h, i) => <li key={i}>{h}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="bootstrap-splash__actions">
|
||||
<button className="bootstrap-splash__retry-btn" onClick={handleRetry} disabled={retrying}>
|
||||
{retrying ? '⏳ ' + t('bootstrap.retrying', 'Retrying…') : '🔄 ' + t('bootstrap.retry', 'Retry')}
|
||||
</button>
|
||||
<button className="bootstrap-splash__retry-btn bootstrap-splash__retry-btn--danger" onClick={handleCleanRetry} disabled={retrying}>
|
||||
<div className="frs-banner__actions frs-banner__actions--end">
|
||||
<button
|
||||
type="button"
|
||||
className="frs-btn frs-btn--quiet"
|
||||
onClick={handleCleanRetry}
|
||||
disabled={retrying}
|
||||
>
|
||||
🧹 {t('bootstrap.clean_retry', 'Clean & Retry')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`frs-btn frs-btn--primary ${retrying ? '' : 'is-armed'}`}
|
||||
onClick={handleRetry}
|
||||
disabled={retrying}
|
||||
>
|
||||
<span className="frs-btn__led" aria-hidden="true" />
|
||||
{retrying ? t('bootstrap.retrying', 'Retrying…') : t('bootstrap.retry', 'Retry')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
</section>
|
||||
) : (
|
||||
<>
|
||||
<div className="bootstrap-splash__bar">
|
||||
<div
|
||||
className="bootstrap-splash__bar-fill"
|
||||
style={{ width: `${((stepIndex + 1) / STEPS.length) * 100}%` }}
|
||||
/>
|
||||
<section className="frs-panel frs-rise" style={{ '--rise': 1 }}>
|
||||
<h2 className="frs-panel__title">{t('firstrun.installing_title', 'Installing')}</h2>
|
||||
{/* Overall journey meter — the same LED segments as the setup
|
||||
screen's disk gate, now measuring progress instead of space. */}
|
||||
<div className="frs-meter frs-meter--progress" role="progressbar" aria-valuenow={Math.round(overallPct)} aria-valuemin={0} aria-valuemax={100}>
|
||||
<span className="frs-meter__fill" style={{ width: `${overallPct}%` }} />
|
||||
</div>
|
||||
{stageProgress && (
|
||||
<div className="bootstrap-splash__sub-progress">
|
||||
<div className="bootstrap-splash__sub-bar">
|
||||
<div
|
||||
className="bootstrap-splash__sub-bar-fill"
|
||||
style={{ width: `${pctFromBytes ?? 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="bootstrap-splash__sub-label">
|
||||
{formatBytes(stageProgress.bytes_done)}
|
||||
{stageProgress.bytes_total > 0
|
||||
? ` / ${formatBytes(stageProgress.bytes_total)}`
|
||||
: ''}
|
||||
{pctFromBytes != null ? ` (${pctFromBytes}%)` : ''}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<ol className="bootstrap-splash__steps">
|
||||
<ol className="frs-steps">
|
||||
{STEPS.map((s, i) => (
|
||||
<li
|
||||
key={s}
|
||||
className={
|
||||
i < stepIndex ? 'done' :
|
||||
i === stepIndex ? 'active' :
|
||||
'pending'
|
||||
}
|
||||
className={[
|
||||
'frs-step',
|
||||
i < stepIndex ? 'is-done' : i === stepIndex ? 'is-active' : 'is-pending',
|
||||
].join(' ')}
|
||||
>
|
||||
{t(`bootstrap.${s}`, STAGE_LABEL[s])}
|
||||
<span className="frs-step__led" aria-hidden="true" />
|
||||
<span className="frs-step__label">{t(`bootstrap.${s}`, STAGE_LABEL[s])}</span>
|
||||
{i === stepIndex && stageProgress && (
|
||||
<span className="frs-step__bytes">
|
||||
{formatBytes(stageProgress.bytes_done)}
|
||||
{stageProgress.bytes_total > 0 ? ` / ${formatBytes(stageProgress.bytes_total)}` : ''}
|
||||
{pctFromBytes != null ? ` (${pctFromBytes}%)` : ''}
|
||||
{stageProgress.bytes_total > 0 && rateRef.current > 0 && stageProgress.bytes_done < stageProgress.bytes_total && (
|
||||
` · ${t('firstrun.eta_left', {
|
||||
eta: formatEta((stageProgress.bytes_total - stageProgress.bytes_done) / rateRef.current),
|
||||
defaultValue: '~{{eta}} left',
|
||||
})}`
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</>
|
||||
)}
|
||||
{/* Live log panel — always visible so users see what's happening */}
|
||||
<div className="bootstrap-splash__log-header">
|
||||
<button
|
||||
type="button"
|
||||
className="bootstrap-splash__log-toggle"
|
||||
onClick={() => setLogsOpen((v) => !v)}
|
||||
>
|
||||
{logsOpen ? '▾ ' + t('bootstrap.hide_logs', 'Hide logs') : '▸ ' + t('bootstrap.show_logs', 'Show logs')}
|
||||
</button>
|
||||
<span className="bootstrap-splash__log-count">
|
||||
{logs.length > 0 && t('bootstrap.lines', { count: logs.length })}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="bootstrap-splash__copy-btn"
|
||||
onClick={handleCopyLogs}
|
||||
>
|
||||
{copied ? '✓ ' + t('bootstrap.copied', 'Copied!') : '📋 ' + t('bootstrap.copy', 'Copy')}
|
||||
</button>
|
||||
</div>
|
||||
{logsOpen && (
|
||||
<pre className="bootstrap-splash__logs" ref={logRef}>
|
||||
{logs.length === 0
|
||||
? t('bootstrap.waiting_output', 'Waiting for output…')
|
||||
: logs.map((l, i) => `[${l.stage}] ${l.line}`).join('\n')}
|
||||
</pre>
|
||||
<p className="frs__trust">
|
||||
{t('firstrun.resume_note', 'Interrupted downloads resume automatically — closing the app is safe.')}
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── Live log — always reachable, quiet by design ───────────────── */}
|
||||
<section className="frs-panel frs-rise" style={{ '--rise': 2 }}>
|
||||
<h2 className="frs-panel__title">
|
||||
{t('firstrun.activity_title', 'Activity')}
|
||||
<span className="frs-log__meta">
|
||||
{logs.length > 0 && t('bootstrap.lines', { count: logs.length })}
|
||||
</span>
|
||||
</h2>
|
||||
<div className="frs-log__bar">
|
||||
<button type="button" className="frs-btn frs-btn--quiet" onClick={() => setLogsOpen(v => !v)}>
|
||||
{logsOpen ? '▾ ' + t('bootstrap.hide_logs', 'Hide logs') : '▸ ' + t('bootstrap.show_logs', 'Show logs')}
|
||||
</button>
|
||||
<button type="button" className="frs-btn frs-btn--quiet" onClick={handleCopyLogs}>
|
||||
{copied ? '✓ ' + t('bootstrap.copied', 'Copied!') : '📋 ' + t('bootstrap.copy', 'Copy')}
|
||||
</button>
|
||||
</div>
|
||||
{logsOpen && (
|
||||
<pre className="frs-log" ref={logRef}>
|
||||
{logs.length === 0
|
||||
? t('bootstrap.waiting_output', 'Waiting for output…')
|
||||
: logs.map((l) => `[${l.stage}] ${l.line}`).join('\n')}
|
||||
</pre>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<footer className="frs__foot frs-rise" style={{ '--rise': 3 }}>
|
||||
<div className="frs__foot-row">
|
||||
<span className="frs__totals">
|
||||
<span className="frs__plate">OVS · v{APP_VERSION}</span>
|
||||
</span>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -392,6 +502,7 @@ export function useBootstrapStage(pollMs = 1000) {
|
||||
|
||||
let cancelled = false;
|
||||
let timer = null;
|
||||
let misses = 0;
|
||||
const invoke = async () => {
|
||||
try {
|
||||
const { invoke: tauriInvoke } = await import('@tauri-apps/api/core');
|
||||
@@ -408,13 +519,24 @@ export function useBootstrapStage(pollMs = 1000) {
|
||||
try {
|
||||
const res = await tauriInvoke('bootstrap_status');
|
||||
if (cancelled) return;
|
||||
misses = 0;
|
||||
// Rust returns { stage: 'ready' } or { stage: 'failed', message: '…' } etc.
|
||||
setState({ stage: res.stage || 'ready', message: res.message || null });
|
||||
if (res.stage !== 'ready' && res.stage !== 'failed') {
|
||||
timer = setTimeout(tick, pollMs);
|
||||
}
|
||||
} catch {
|
||||
setState({ stage: 'ready', message: null });
|
||||
// A transient IPC hiccup (e.g. the very first poll racing webview
|
||||
// init) must NOT permanently declare 'ready' — that kills the poll
|
||||
// loop and silently skips the awaiting_setup / progress screens.
|
||||
// Retry a few times before conceding.
|
||||
misses += 1;
|
||||
if (cancelled) return;
|
||||
if (misses < 5) {
|
||||
timer = setTimeout(tick, pollMs);
|
||||
} else {
|
||||
setState({ stage: 'ready', message: null });
|
||||
}
|
||||
}
|
||||
};
|
||||
tick();
|
||||
|
||||
@@ -194,8 +194,12 @@ export default function DictationDemo({ embedded = false }) {
|
||||
}
|
||||
})();
|
||||
|
||||
// No bundled samples on disk → don't render a demo that can't work.
|
||||
if (assetsAvailable === false) return null;
|
||||
// The hotkey card always has something real to teach (the registered
|
||||
// shortcut + live press-to-verify) — only the replayable script cards
|
||||
// depend on the bundled WAVs, which installs don't always ship. Hiding
|
||||
// the whole panel left the wizard's "Try dictation" act completely
|
||||
// blank on every such install (#119/#124 follow-up, refined).
|
||||
const showScripts = assetsAvailable !== false;
|
||||
|
||||
return (
|
||||
<section className={`dictation-demo ${embedded ? 'dictation-demo--embedded' : ''}`}>
|
||||
@@ -206,10 +210,16 @@ export default function DictationDemo({ embedded = false }) {
|
||||
{statusBadge}
|
||||
</header>
|
||||
|
||||
<p className="dictation-demo__lede">{t('demo.dictation_lede')}</p>
|
||||
<p className="dictation-demo__lede">
|
||||
{showScripts
|
||||
? t('demo.dictation_lede')
|
||||
: t('demo.dictation_lede_hotkey_only',
|
||||
'Hold the shortcut above anywhere on your desktop, speak, release — the text lands in whatever app has focus. Press it now to verify it works.')}
|
||||
</p>
|
||||
|
||||
<audio ref={audioRef} onEnded={() => setPlayingId(null)} preload="none" />
|
||||
|
||||
{showScripts && (
|
||||
<div className="dictation-demo__scripts">
|
||||
{SCRIPTS.map((s) => {
|
||||
const isPlaying = playingId === s.id;
|
||||
@@ -256,6 +266,7 @@ export default function DictationDemo({ embedded = false }) {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Cpu, Mic, MessageSquare, Activity, AlertTriangle, CheckCircle2, RefreshCw, Layers } from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { toastErrorWithReport } from '../utils/errorToast';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { listEngines, getEngineHealth } from '../api/engines';
|
||||
import { Badge, Button, Segmented, Table } from '../ui';
|
||||
@@ -126,7 +126,7 @@ export default function EngineCompatibilityMatrix({
|
||||
} catch (e) {
|
||||
const msg = e?.message || String(e);
|
||||
setError(msg);
|
||||
toast.error(t('engines.loadFailed', { message: msg }));
|
||||
toastErrorWithReport(t('engines.loadFailed', { message: msg }), e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import React from 'react';
|
||||
import { AlertCircle, BookOpen, RefreshCw } from 'lucide-react';
|
||||
import { AlertCircle, BookOpen, Bug, RefreshCw, Search } from 'lucide-react';
|
||||
import i18next from 'i18next';
|
||||
import { classifyError, openDocsFor } from '../utils/errorDocsMap';
|
||||
import { openExternal } from '../api/external';
|
||||
import { buildBugReportUrl, buildIssueSearchUrl } from '../utils/bugReport';
|
||||
import './WaveformErrorBoundary.css';
|
||||
|
||||
export default class ErrorBoundary extends React.Component {
|
||||
@@ -36,6 +38,26 @@ export default class ErrorBoundary extends React.Component {
|
||||
}
|
||||
};
|
||||
|
||||
report = async () => {
|
||||
// Prefilled GitHub Issues URL with the scrubbed error attached — the
|
||||
// user reviews everything on github.com before anything is submitted.
|
||||
try {
|
||||
await openExternal(await buildBugReportUrl({ error: this.state.error }));
|
||||
} catch (err) {
|
||||
console.warn('[ErrorBoundary] report failed', err);
|
||||
}
|
||||
};
|
||||
|
||||
searchIssues = async () => {
|
||||
// "Has someone already hit this?" — issue search in the browser, so a
|
||||
// duplicate gets a 👍 on the existing thread instead of a new report.
|
||||
try {
|
||||
await openExternal(buildIssueSearchUrl(this.state.error));
|
||||
} catch (err) {
|
||||
console.warn('[ErrorBoundary] issue search failed', err);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
if (!this.state.error) return this.props.children;
|
||||
|
||||
@@ -66,6 +88,22 @@ export default class ErrorBoundary extends React.Component {
|
||||
>
|
||||
<BookOpen size={12} /> {i18next.t('errors.openDocs')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={this.searchIssues}
|
||||
className="btn-secondary errbnd-search"
|
||||
title={i18next.t('errors.searchIssues')}
|
||||
>
|
||||
<Search size={12} /> {i18next.t('errors.searchIssues')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={this.report}
|
||||
className="btn-secondary errbnd-report"
|
||||
title={i18next.t('reportBug.title')}
|
||||
>
|
||||
<Bug size={12} /> {i18next.t('errors.report')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,936 @@
|
||||
/* First-run install setup — "studio console".
|
||||
*
|
||||
* Desktop-first: a wide deck of rack-unit panels floating directly on an
|
||||
* atmospheric backdrop (no outer chassis box). Engraved mono labels, serif
|
||||
* masthead, a breathing waveform, LED capacity meters for the disk gate,
|
||||
* LED option cards, and an "armed" install button.
|
||||
*
|
||||
* Constraints honored:
|
||||
* - every font/asset is bundled (first runs may be on restricted networks)
|
||||
* - all motion is transform/opacity only and respects reduced-motion
|
||||
* - colors derive from the app's chrome tokens so themes stay coherent
|
||||
*/
|
||||
|
||||
.frs {
|
||||
--frs-accent: var(--chrome-accent, #e8a3b4);
|
||||
--frs-ok: var(--chrome-severity-ok, #98971a);
|
||||
--frs-err: var(--chrome-severity-err, #d4554a);
|
||||
--frs-ink: var(--chrome-fg, #ece6dd);
|
||||
--frs-bg: var(--chrome-bg, #121013);
|
||||
--frs-line: color-mix(in srgb, var(--frs-ink) 11%, transparent);
|
||||
--frs-line-strong: color-mix(in srgb, var(--frs-ink) 20%, transparent);
|
||||
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
background: var(--frs-bg);
|
||||
color: var(--frs-ink);
|
||||
font-family: var(--font-sans, 'Inter Variable', system-ui, sans-serif);
|
||||
z-index: 9999;
|
||||
/* Extra top clearance: the native titlebar (GTK headerbar / macOS
|
||||
traffic lights / Windows controls) overlays the top of the window —
|
||||
content must start below it, never under it. */
|
||||
padding: 3.4rem 2.5rem 2rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* No backdrop decoration: the journey sits on a clean flat surface — corner
|
||||
glows and grain read as banding/noise artifacts on many panels. The empty
|
||||
.frs__atmo element is kept harmless for layout stability. */
|
||||
.frs__atmo { display: none; }
|
||||
|
||||
/* ── Deck: the whole console, borderless, wide ─────────────────────────── */
|
||||
|
||||
.frs__deck {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 1240px;
|
||||
margin: auto 0; /* vertical centering when content is short */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.frs__loading {
|
||||
margin: auto;
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* ── Entry choreography: everything rises in, staggered ────────────────── */
|
||||
|
||||
.frs-rise {
|
||||
animation: frs-rise 640ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
animation-delay: calc(var(--rise, 0) * 80ms);
|
||||
}
|
||||
|
||||
@keyframes frs-rise {
|
||||
from { opacity: 0; transform: translateY(14px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* ── Masthead ──────────────────────────────────────────────────────────── */
|
||||
|
||||
.frs__mast {
|
||||
padding-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.frs__mast-row {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 2rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.frs__title {
|
||||
margin: 0;
|
||||
font-family: var(--font-serif, 'Source Serif 4 Variable', Georgia, serif);
|
||||
font-size: clamp(1.7rem, 3.2vw, 2.3rem);
|
||||
font-weight: 620;
|
||||
letter-spacing: -0.014em;
|
||||
line-height: 1.08;
|
||||
}
|
||||
|
||||
.frs__subtitle {
|
||||
margin: 0.5rem 0 0;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.5;
|
||||
opacity: 0.72;
|
||||
max-width: 62ch;
|
||||
}
|
||||
|
||||
.frs__mast-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 0.45rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.frs__mast-selects {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Mirrors hang under the region select they extend; the fields open as a
|
||||
right-aligned column so the masthead stays balanced. */
|
||||
.frs__advanced--mast { text-align: right; }
|
||||
.frs__advanced--mast .frs__mirror-fields {
|
||||
grid-template-columns: 1fr;
|
||||
min-width: 320px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* Serial: quiet engraved model/version text, no badge box. */
|
||||
.frs__plate {
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.14em;
|
||||
opacity: 0.6;
|
||||
font-variant-numeric: tabular-nums;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Waveform: a voice, breathing across the full width ────────────────── */
|
||||
|
||||
/* Whisper-quiet: a thin breathing trace, not a billboard. */
|
||||
.frs-wave {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 22px;
|
||||
overflow: hidden;
|
||||
mask-image: linear-gradient(90deg, transparent, #000 5%, #000 95%, transparent);
|
||||
-webkit-mask-image: linear-gradient(90deg, transparent, #000 5%, #000 95%, transparent);
|
||||
}
|
||||
|
||||
.frs-wave__bar {
|
||||
flex: 1 0 auto;
|
||||
width: 2px;
|
||||
height: 100%;
|
||||
border-radius: 1px;
|
||||
background: color-mix(in srgb, var(--frs-accent) 70%, transparent);
|
||||
transform: scaleY(var(--h, 0.4));
|
||||
transform-origin: center;
|
||||
opacity: 0.45;
|
||||
animation: frs-breathe 2.8s ease-in-out infinite alternate;
|
||||
animation-delay: var(--d, 0ms);
|
||||
}
|
||||
|
||||
@keyframes frs-breathe {
|
||||
from { transform: scaleY(calc(var(--h, 0.4) * 0.55)); opacity: 0.22; }
|
||||
to { transform: scaleY(var(--h, 0.4)); opacity: 0.65; }
|
||||
}
|
||||
|
||||
/* ── Wide grid: storage rail (left, 7fr) + decision rail (right, 5fr) ──── */
|
||||
|
||||
.frs__grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 7fr) minmax(0, 5fr);
|
||||
gap: 1.1rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.frs__col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* ── Sections: no boxes — an engraved title rule and whitespace carry the
|
||||
structure. Borders appear only where state demands them. ─────────────── */
|
||||
|
||||
.frs-panel {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.frs-panel__title {
|
||||
margin: 0;
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.22em;
|
||||
opacity: 0.68;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
/* Engraved rule running out from the title. */
|
||||
.frs-panel__title::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, var(--frs-line-strong), transparent);
|
||||
}
|
||||
|
||||
/* ── LED option cards (mode / compute / channel) ───────────────────────── */
|
||||
|
||||
.frs__options {
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.frs__options--two { grid-template-columns: 1fr 1fr; }
|
||||
|
||||
.frs-opt {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
padding: 0.75rem 2rem 0.8rem 0.9rem;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
background: color-mix(in srgb, var(--frs-ink) 4%, transparent);
|
||||
border: none;
|
||||
border-radius: 9px;
|
||||
cursor: pointer;
|
||||
transition: background 140ms ease, transform 140ms ease, box-shadow 140ms ease;
|
||||
}
|
||||
|
||||
.frs-opt--compact { padding-top: 0.6rem; padding-bottom: 0.65rem; }
|
||||
|
||||
.frs-opt:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--frs-ink) 7%, transparent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.frs-opt.is-active {
|
||||
background: color-mix(in srgb, var(--frs-accent) 10%, transparent);
|
||||
}
|
||||
|
||||
.frs-opt:disabled { opacity: 0.42; cursor: not-allowed; }
|
||||
|
||||
.frs-opt__led {
|
||||
position: absolute;
|
||||
top: 0.75rem;
|
||||
right: 0.75rem;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--frs-ink) 14%, transparent);
|
||||
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.6);
|
||||
transition: background 160ms ease, box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
.frs-opt.is-active .frs-opt__led {
|
||||
background: var(--frs-accent);
|
||||
box-shadow:
|
||||
0 0 6px 1px color-mix(in srgb, var(--frs-accent) 70%, transparent),
|
||||
inset 0 0 2px rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.frs-opt__head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.frs-opt__name { font-size: 0.82rem; font-weight: 650; letter-spacing: 0.01em; }
|
||||
|
||||
.frs-opt__badge {
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 0.56rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
color: color-mix(in srgb, var(--frs-ok) 85%, var(--frs-ink));
|
||||
background: color-mix(in srgb, var(--frs-ok) 14%, transparent);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Verbosity diet: the description unfolds (smoothly) only on the selected
|
||||
card — collapsed cards keep it as a tooltip. */
|
||||
.frs-opt__desc {
|
||||
font-size: 0.7rem;
|
||||
line-height: 1.45;
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 260ms cubic-bezier(0.22, 1, 0.36, 1), opacity 260ms ease;
|
||||
}
|
||||
|
||||
.frs-opt.is-active .frs-opt__desc {
|
||||
max-height: 4.5em;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* ── Detected hardware readout (Compute panel) ─────────────────────────── */
|
||||
|
||||
.frs__hw {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.1rem 0.1rem 0.3rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.frs__hw-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
background: var(--frs-ok);
|
||||
box-shadow: 0 0 6px 1px color-mix(in srgb, var(--frs-ok) 60%, transparent);
|
||||
animation: frs-hw-pulse 2.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes frs-hw-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.45; }
|
||||
}
|
||||
|
||||
.frs__hw-label {
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 0.58rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
opacity: 0.65;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.frs__hw-value {
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 0.66rem;
|
||||
opacity: 0.85;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ── Storage rows + LED capacity meters ────────────────────────────────── */
|
||||
|
||||
.frs-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.6rem 0.8rem;
|
||||
border-radius: 9px;
|
||||
transition: background 160ms ease;
|
||||
}
|
||||
|
||||
.frs-row:hover { background: color-mix(in srgb, var(--frs-ink) 4%, transparent); }
|
||||
|
||||
/* Blocked: a red tint + edge bar — state, not another box. */
|
||||
.frs-row--blocked {
|
||||
background: color-mix(in srgb, var(--frs-err) 6%, transparent);
|
||||
box-shadow: inset 2px 0 0 color-mix(in srgb, var(--frs-err) 70%, transparent);
|
||||
}
|
||||
|
||||
.frs-row__text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.12rem;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.frs-row__label { font-size: 0.8rem; font-weight: 620; }
|
||||
|
||||
.frs-row__path {
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 0.66rem;
|
||||
opacity: 0.65;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 52ch;
|
||||
direction: rtl; /* ellipsize the head — the tail of a path matters */
|
||||
text-align: left;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
.frs-row__gauge {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 0.3rem;
|
||||
min-width: 170px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.frs-row__gauge .frs-meter { width: 100%; }
|
||||
|
||||
/* One quiet line: "needs ~9 GB · 449 GB free". */
|
||||
.frs-row__readout {
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 0.64rem;
|
||||
opacity: 0.68;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.frs-row__readout.is-low {
|
||||
color: var(--frs-err);
|
||||
opacity: 1;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* The meter: segmented LEDs. Lit = consumed by the install, dim = headroom. */
|
||||
.frs-meter {
|
||||
position: relative;
|
||||
height: 10px;
|
||||
border-radius: 3px;
|
||||
background: color-mix(in srgb, var(--frs-ink) 7%, transparent);
|
||||
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.55);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.frs-meter__fill {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
border-radius: 3px 0 0 3px;
|
||||
background: linear-gradient(90deg,
|
||||
color-mix(in srgb, var(--frs-ok) 80%, var(--frs-ink)),
|
||||
var(--frs-accent));
|
||||
/* LED segmentation: 5px lit / 2px gap notches. */
|
||||
-webkit-mask-image: repeating-linear-gradient(90deg, #000 0 5px, transparent 5px 7px);
|
||||
mask-image: repeating-linear-gradient(90deg, #000 0 5px, transparent 5px 7px);
|
||||
transition: width 480ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.frs-meter--over .frs-meter__fill {
|
||||
background: var(--frs-err);
|
||||
animation: frs-alarm 1s steps(2, jump-none) infinite;
|
||||
}
|
||||
|
||||
@keyframes frs-alarm {
|
||||
to { opacity: 0.45; }
|
||||
}
|
||||
|
||||
/* ── Fields ────────────────────────────────────────────────────────────── */
|
||||
|
||||
.frs-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.28rem;
|
||||
font-size: 0.7rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.frs-field > span {
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 0.6rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.frs-select,
|
||||
.frs-input {
|
||||
background: color-mix(in srgb, var(--frs-ink) 8%, transparent);
|
||||
border: none;
|
||||
color: inherit;
|
||||
color-scheme: dark;
|
||||
font: inherit;
|
||||
font-size: 0.76rem;
|
||||
padding: 0.45rem 0.6rem;
|
||||
border-radius: 7px;
|
||||
min-width: 0;
|
||||
transition: background 140ms ease;
|
||||
}
|
||||
|
||||
.frs-select:hover,
|
||||
.frs-input:hover { background: color-mix(in srgb, var(--frs-ink) 12%, transparent); }
|
||||
|
||||
.frs-select:focus-visible,
|
||||
.frs-input:focus-visible,
|
||||
.frs-opt:focus-visible,
|
||||
.frs-btn:focus-visible {
|
||||
outline: 2px solid color-mix(in srgb, var(--frs-accent) 65%, transparent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.frs-select { cursor: pointer; }
|
||||
|
||||
.frs-select option {
|
||||
background: var(--frs-bg);
|
||||
color: var(--frs-ink);
|
||||
}
|
||||
|
||||
.frs-select--lang { font-size: 0.7rem; padding: 0.3rem 0.5rem; }
|
||||
|
||||
.frs-input::placeholder { opacity: 0.32; }
|
||||
|
||||
/* ── Advanced mirrors disclosure ───────────────────────────────────────── */
|
||||
|
||||
.frs__advanced { min-width: 0; }
|
||||
|
||||
.frs__advanced summary {
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 0.62rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
opacity: 0.65;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
padding: 0.15rem 0;
|
||||
list-style: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.frs__advanced summary::-webkit-details-marker { display: none; }
|
||||
|
||||
.frs__advanced summary::before {
|
||||
content: '▸';
|
||||
font-size: 0.55rem;
|
||||
transition: transform 140ms ease;
|
||||
}
|
||||
|
||||
.frs__advanced[open] summary::before { transform: rotate(90deg); }
|
||||
|
||||
.frs__advanced summary:hover { opacity: 0.85; }
|
||||
|
||||
.frs__mirror-fields {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 0.6rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
/* ── Footer: gate + armed button ───────────────────────────────────────── */
|
||||
|
||||
.frs__foot {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
padding-top: 0.6rem;
|
||||
}
|
||||
|
||||
.frs__foot-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.frs__totals {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.72rem;
|
||||
opacity: 0.68;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.frs__totals-sep { opacity: 0.65; }
|
||||
|
||||
.frs__blocker {
|
||||
margin: 0;
|
||||
font-size: 0.74rem;
|
||||
color: var(--frs-err);
|
||||
}
|
||||
|
||||
.frs__error {
|
||||
margin: 0;
|
||||
padding: 0.5rem 0.7rem;
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 0.66rem;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
color: var(--frs-err);
|
||||
background: color-mix(in srgb, var(--frs-err) 8%, transparent);
|
||||
border-radius: 8px;
|
||||
box-shadow: inset 2px 0 0 color-mix(in srgb, var(--frs-err) 70%, transparent);
|
||||
}
|
||||
|
||||
/* ── Buttons ───────────────────────────────────────────────────────────── */
|
||||
|
||||
.frs-btn {
|
||||
font: inherit;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 650;
|
||||
padding: 0.5rem 1.15rem;
|
||||
border-radius: 9px;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: background 140ms ease, border-color 140ms ease,
|
||||
opacity 140ms ease, box-shadow 240ms ease, transform 140ms ease;
|
||||
}
|
||||
|
||||
.frs-btn:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||
|
||||
.frs-btn--primary {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: var(--frs-accent);
|
||||
color: var(--frs-bg);
|
||||
padding: 0.55rem 1.4rem;
|
||||
}
|
||||
|
||||
.frs-btn__led {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--frs-bg) 55%, transparent);
|
||||
transition: background 200ms ease, box-shadow 200ms ease;
|
||||
}
|
||||
|
||||
/* Armed: the button is live — LED lights, halo pulses. The single loudest
|
||||
element on screen, exactly when it becomes actionable. */
|
||||
.frs-btn--primary.is-armed .frs-btn__led {
|
||||
background: var(--frs-bg);
|
||||
box-shadow: 0 0 5px 1px color-mix(in srgb, var(--frs-bg) 60%, transparent);
|
||||
}
|
||||
|
||||
.frs-btn--primary.is-armed:hover {
|
||||
transform: translateY(-1px);
|
||||
background: color-mix(in srgb, var(--frs-accent) 88%, white);
|
||||
}
|
||||
|
||||
/* Quiet picker: reads as a text action until pointed at. */
|
||||
.frs-btn--quiet {
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
opacity: 0.65;
|
||||
font-weight: 500;
|
||||
padding: 0.34rem 0.6rem;
|
||||
font-size: 0.68rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.frs-btn--quiet:hover:not(:disabled) {
|
||||
opacity: 1;
|
||||
background: color-mix(in srgb, var(--frs-ink) 9%, transparent);
|
||||
}
|
||||
|
||||
/* ── Reduced motion: hold every frame still ────────────────────────────── */
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.frs-rise { animation: none; }
|
||||
.frs-wave__bar { animation: none; }
|
||||
.frs-meter--over .frs-meter__fill { animation: none; }
|
||||
.frs__hw-dot { animation: none; }
|
||||
.frs-meter__fill { transition: none; }
|
||||
}
|
||||
|
||||
/* ── Responsive: deck collapses gracefully ─────────────────────────────── */
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.frs { padding: 1.5rem; }
|
||||
.frs__grid { grid-template-columns: 1fr; }
|
||||
.frs__mirror-fields { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.frs { padding: 1rem; }
|
||||
.frs__mast-row { flex-direction: column; align-items: flex-start; gap: 0.8rem; }
|
||||
.frs__options--two { grid-template-columns: 1fr; }
|
||||
.frs-row { flex-wrap: wrap; }
|
||||
.frs-row__gauge { width: 100%; }
|
||||
.frs__foot-row { flex-direction: column; align-items: stretch; }
|
||||
.frs-btn--primary { justify-content: center; }
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════════════
|
||||
Shared first-run journey pieces — used by the installing screen
|
||||
(BootstrapSplash) and the model wizard (SetupWizard) so the whole
|
||||
setup → install → models flow speaks one visual language.
|
||||
═══════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* Focused acts (installing) read better narrow. */
|
||||
.frs__deck--focus { max-width: 760px; }
|
||||
|
||||
/* ── LED step rail ─────────────────────────────────────────────────────── */
|
||||
|
||||
.frs-steps {
|
||||
list-style: none;
|
||||
margin: 0.2rem 0 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.frs-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
font-size: 0.78rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.frs-step__led {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
background: color-mix(in srgb, var(--frs-ink) 14%, transparent);
|
||||
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.6);
|
||||
transition: background 200ms ease, box-shadow 200ms ease;
|
||||
}
|
||||
|
||||
.frs-step.is-done .frs-step__led {
|
||||
background: var(--frs-ok);
|
||||
box-shadow: 0 0 5px 1px color-mix(in srgb, var(--frs-ok) 50%, transparent);
|
||||
}
|
||||
|
||||
.frs-step.is-active .frs-step__led {
|
||||
background: var(--frs-accent);
|
||||
box-shadow: 0 0 6px 1px color-mix(in srgb, var(--frs-accent) 70%, transparent);
|
||||
animation: frs-hw-pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.frs-step.is-done .frs-step__label { opacity: 0.68; }
|
||||
.frs-step.is-active .frs-step__label { font-weight: 650; }
|
||||
.frs-step.is-pending { opacity: 0.45; }
|
||||
|
||||
.frs-step__bytes {
|
||||
margin-left: auto;
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 0.64rem;
|
||||
opacity: 0.6;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Progress variant of the LED meter — taller, journey-wide. */
|
||||
.frs-meter--progress { height: 8px; }
|
||||
|
||||
/* ── Live log panel ────────────────────────────────────────────────────── */
|
||||
|
||||
.frs-log__bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.frs-log__meta {
|
||||
margin-left: auto;
|
||||
font-size: 0.6rem;
|
||||
letter-spacing: 0.08em;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.frs-log {
|
||||
margin: 0;
|
||||
padding: 0.6rem 0.75rem;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 0.64rem;
|
||||
line-height: 1.55;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
color: color-mix(in srgb, var(--frs-ink) 70%, transparent);
|
||||
background: color-mix(in srgb, var(--frs-bg) 55%, transparent);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* ── Inline banner (e.g. language suggestion) ──────────────────────────── */
|
||||
|
||||
.frs-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.8rem;
|
||||
padding: 0.5rem 0.8rem;
|
||||
border-radius: 9px;
|
||||
font-size: 0.76rem;
|
||||
background: color-mix(in srgb, var(--frs-accent) 8%, transparent);
|
||||
}
|
||||
|
||||
.frs-banner__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.frs-banner__actions--end { justify-content: flex-end; margin-top: 0.3rem; }
|
||||
|
||||
/* ── Failure hints ─────────────────────────────────────────────────────── */
|
||||
|
||||
.frs-hints { font-size: 0.74rem; line-height: 1.5; }
|
||||
|
||||
.frs-hints__label { font-weight: 650; }
|
||||
|
||||
.frs-hints ul {
|
||||
margin: 0.35rem 0 0;
|
||||
padding-left: 1.1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* ── Wizard chrome (model/engine selection act) ────────────────────────── */
|
||||
|
||||
.frs-wsteps {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.9rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.frs-wstep {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
font: inherit;
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
color: inherit;
|
||||
opacity: 0.45;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0.25rem 0.1rem;
|
||||
cursor: pointer;
|
||||
transition: opacity 140ms ease;
|
||||
}
|
||||
|
||||
.frs-wstep:hover { opacity: 0.8; }
|
||||
.frs-wstep.is-active { opacity: 1; }
|
||||
.frs-wstep.is-done { opacity: 0.7; }
|
||||
|
||||
.frs-wstep__led {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--frs-ink) 14%, transparent);
|
||||
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.frs-wstep.is-active .frs-wstep__led {
|
||||
background: var(--frs-accent);
|
||||
box-shadow: 0 0 6px 1px color-mix(in srgb, var(--frs-accent) 70%, transparent);
|
||||
}
|
||||
|
||||
.frs-wstep.is-done .frs-wstep__led {
|
||||
background: var(--frs-ok);
|
||||
box-shadow: 0 0 5px 1px color-mix(in srgb, var(--frs-ok) 50%, transparent);
|
||||
}
|
||||
|
||||
/* Journey rail on setup/install acts: a quiet breadcrumb of the three
|
||||
stages, between the waveform and the headline. Non-interactive spans. */
|
||||
.frs-wsteps--journey {
|
||||
margin-top: 0.8rem;
|
||||
gap: 1.2rem;
|
||||
}
|
||||
|
||||
.frs-wsteps--journey .frs-wstep { cursor: default; }
|
||||
|
||||
/* Embedded app panels (Model Store / Engines) keep their own internals;
|
||||
this shell just gives them breathing room inside the act. */
|
||||
.frs-embed { min-width: 0; }
|
||||
|
||||
.frs-wnav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding-top: 0.6rem;
|
||||
}
|
||||
|
||||
.frs-wnav__group { display: flex; align-items: center; gap: 0.5rem; }
|
||||
|
||||
/* Status check rows (preflight) — same row language as storage. */
|
||||
.frs-check {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.6rem;
|
||||
padding: 0.5rem 0.7rem;
|
||||
border-radius: 9px;
|
||||
}
|
||||
|
||||
.frs-check:hover { background: color-mix(in srgb, var(--frs-ink) 4%, transparent); }
|
||||
|
||||
.frs-check__led {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
margin-top: 0.35rem;
|
||||
background: color-mix(in srgb, var(--frs-ink) 14%, transparent);
|
||||
}
|
||||
|
||||
.frs-check--pass .frs-check__led {
|
||||
background: var(--frs-ok);
|
||||
box-shadow: 0 0 5px 1px color-mix(in srgb, var(--frs-ok) 50%, transparent);
|
||||
}
|
||||
|
||||
.frs-check--warn .frs-check__led {
|
||||
background: var(--chrome-severity-warn, #d79921);
|
||||
box-shadow: 0 0 5px 1px color-mix(in srgb, var(--chrome-severity-warn, #d79921) 50%, transparent);
|
||||
}
|
||||
|
||||
.frs-check--fail .frs-check__led {
|
||||
background: var(--frs-err);
|
||||
box-shadow: 0 0 5px 1px color-mix(in srgb, var(--frs-err) 50%, transparent);
|
||||
}
|
||||
|
||||
.frs-check__body { display: flex; flex-direction: column; gap: 0.12rem; min-width: 0; }
|
||||
.frs-check__title { font-size: 0.78rem; font-weight: 620; }
|
||||
.frs-check__detail { font-size: 0.7rem; opacity: 0.6; line-height: 1.45; }
|
||||
.frs-check__fix { font-size: 0.7rem; line-height: 1.45; }
|
||||
.frs-check--fail .frs-check__fix { color: var(--frs-err); }
|
||||
.frs-check--warn .frs-check__fix { color: var(--chrome-severity-warn, #d79921); }
|
||||
|
||||
/* Quiet reassurance lines (trust statement, resume note) — present, never loud. */
|
||||
.frs__trust {
|
||||
margin: 0;
|
||||
font-size: 0.68rem;
|
||||
line-height: 1.5;
|
||||
opacity: 0.45;
|
||||
}
|
||||
@@ -0,0 +1,604 @@
|
||||
/**
|
||||
* First-run install setup screen — "studio console" treatment.
|
||||
*
|
||||
* Rendered by BootstrapSplash while the Rust side is parked in the
|
||||
* `awaiting_setup` stage — nothing has been downloaded or installed yet.
|
||||
* The user picks install mode (installed/portable), storage locations,
|
||||
* compute variant, network mirrors and update channel; every chosen
|
||||
* directory is live-checked for free space against the minimum the install
|
||||
* needs (Rust re-validates on submit — the UI gate is a mirror, not the
|
||||
* authority). "Start installation" is the only thing that kicks off the
|
||||
* bootstrap.
|
||||
*
|
||||
* Design language: powering on a piece of studio hardware. Serif masthead
|
||||
* (Source Serif 4), engraved mono panel labels (IBM Plex Mono), a breathing
|
||||
* waveform, and disk space rendered as LED capacity meters. Desktop-first:
|
||||
* a wide two-column deck of rack panels floating directly on the backdrop
|
||||
* (no outer chassis box), collapsing to one column on narrow windows. All
|
||||
* motion is CSS-only (transform/opacity) and honors prefers-reduced-motion;
|
||||
* every asset is bundled — a first run may be on a restricted network.
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import i18n, { LANGUAGES } from '../i18n';
|
||||
import { useAppStore } from '../store';
|
||||
import './FirstRunSetup.css';
|
||||
|
||||
const APP_VERSION = __APP_VERSION__ || '0.0.0';
|
||||
const GIB = 1024 * 1024 * 1024;
|
||||
|
||||
const fmtGB = (bytes) =>
|
||||
bytes == null ? '—' : `${(bytes / GIB).toFixed(bytes < 10 * GIB ? 1 : 0)} GB`;
|
||||
|
||||
const invoke = async (...args) => {
|
||||
const { invoke: tauriInvoke } = await import('@tauri-apps/api/core');
|
||||
return tauriInvoke(...args);
|
||||
};
|
||||
|
||||
/** Debounced live probe of one install target (free space / writability). */
|
||||
function useTargetCheck(path) {
|
||||
const [check, setCheck] = useState(null);
|
||||
useEffect(() => {
|
||||
if (!path) { setCheck(null); return; }
|
||||
let cancelled = false;
|
||||
const t = setTimeout(async () => {
|
||||
try {
|
||||
const res = await invoke('check_install_target', { path });
|
||||
if (!cancelled) setCheck(res);
|
||||
} catch { if (!cancelled) setCheck(null); }
|
||||
}, 250);
|
||||
return () => { cancelled = true; clearTimeout(t); };
|
||||
}, [path]);
|
||||
return check;
|
||||
}
|
||||
|
||||
/** Breathing waveform masthead — bar heights are stable per mount. */
|
||||
function Waveform({ bars = 96 }) {
|
||||
const heights = useMemo(
|
||||
() => Array.from({ length: bars }, (_, i) => {
|
||||
// Deterministic pseudo-random silhouette: layered sines read as speech
|
||||
// cadence (syllables + phrase envelope) rather than white noise.
|
||||
const t = i / bars;
|
||||
const v = Math.abs(
|
||||
Math.sin(t * Math.PI * 7.3) * 0.55 +
|
||||
Math.sin(t * Math.PI * 2.1 + 1.2) * 0.3 +
|
||||
Math.sin(t * Math.PI * 17.0 + 0.4) * 0.15
|
||||
);
|
||||
return 0.18 + v * 0.82;
|
||||
}),
|
||||
[bars],
|
||||
);
|
||||
return (
|
||||
<div className="frs-wave" aria-hidden="true">
|
||||
{heights.map((h, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="frs-wave__bar"
|
||||
style={{ '--h': h, '--d': `${(i * 73) % 1400}ms` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* LED capacity meter: how much of the volume's free space this install
|
||||
* consumes. Lit = consumed by the install, dim = remaining headroom.
|
||||
* Overflows (need > free) clamp to full and switch to the alarm color.
|
||||
*/
|
||||
function CapacityMeter({ need, free }) {
|
||||
const ratio = free > 0 ? need / free : 1;
|
||||
const pct = Math.min(100, Math.max(3, ratio * 100));
|
||||
return (
|
||||
<div
|
||||
className={`frs-meter ${ratio > 1 ? 'frs-meter--over' : ''}`}
|
||||
role="img"
|
||||
aria-label={`${fmtGB(need)} / ${fmtGB(free)}`}
|
||||
>
|
||||
<span className="frs-meter__fill" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** One storage location row: label, path, space readout, Change… picker.
|
||||
* The LED meter only appears when it carries information — the disk is
|
||||
* getting tight (install would consume >35% of free space) or blocked.
|
||||
* At 449 GB free vs 9 GB needed a bar is a meaningless sliver; a quiet
|
||||
* one-line readout is cleaner. */
|
||||
function StorageRow({ label, desc, path, need, check, onPick }) {
|
||||
const { t } = useTranslation();
|
||||
const lowSpace = check?.freeBytes != null && check.freeBytes < need;
|
||||
const notWritable = check && !check.writable;
|
||||
const blocked = lowSpace || notWritable;
|
||||
const tight = check?.freeBytes != null && need / check.freeBytes > 0.35;
|
||||
return (
|
||||
<div className={`frs-row ${blocked ? 'frs-row--blocked' : ''}`}>
|
||||
<div className="frs-row__text" title={desc}>
|
||||
<span className="frs-row__label">{label}</span>
|
||||
<code className="frs-row__path" title={path}>{path}</code>
|
||||
</div>
|
||||
<div className="frs-row__gauge">
|
||||
{(blocked || tight) && check?.freeBytes != null && (
|
||||
<CapacityMeter need={need} free={check.freeBytes} />
|
||||
)}
|
||||
<span className={`frs-row__readout ${lowSpace ? 'is-low' : ''}`}>
|
||||
{check == null
|
||||
? t('firstrun.checking', 'checking…')
|
||||
: notWritable
|
||||
? t('firstrun.not_writable', 'not writable')
|
||||
: <>
|
||||
{t('firstrun.needs', { size: fmtGB(need), defaultValue: 'needs ~{{size}}' })}
|
||||
{' · '}
|
||||
{t('firstrun.free', { size: fmtGB(check.freeBytes), defaultValue: '{{size}} free' })}
|
||||
</>}
|
||||
</span>
|
||||
</div>
|
||||
{onPick && (
|
||||
<button type="button" className="frs-btn frs-btn--quiet" onClick={onPick}>
|
||||
{t('firstrun.change', 'Change…')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Section: engraved mono title + rule — structure by line, not by box. */
|
||||
function Panel({ title, delay, className = '', children }) {
|
||||
return (
|
||||
<section className={`frs-panel frs-rise ${className}`} style={{ '--rise': delay }}>
|
||||
<h2 className="frs-panel__title">{title}</h2>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** Arrow-key navigation for a radio group (WAI-ARIA radio pattern):
|
||||
* Left/Up selects the previous enabled option, Right/Down the next.
|
||||
* Selection follows focus, exactly like native radios. */
|
||||
export function radioGroupNav(e, values, current, select) {
|
||||
let delta = 0;
|
||||
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') delta = 1;
|
||||
else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') delta = -1;
|
||||
else return;
|
||||
e.preventDefault();
|
||||
const idx = Math.max(0, values.indexOf(current));
|
||||
const next = values[(idx + delta + values.length) % values.length];
|
||||
select(next);
|
||||
}
|
||||
|
||||
/** LED radio option — used for install mode, compute and update channel.
|
||||
* Verbosity diet: the description unfolds only on the selected card; the
|
||||
* rest expose it as a tooltip. One expanded card per group keeps the page
|
||||
* calm without hiding information. Roving tabindex: only the selected
|
||||
* option is in the tab order; arrows move within the group. */
|
||||
function OptionCard({ active, disabled, onSelect, name, desc, badge, compact }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={active}
|
||||
tabIndex={active ? 0 : -1}
|
||||
className={`frs-opt ${compact ? 'frs-opt--compact' : ''} ${active ? 'is-active' : ''}`}
|
||||
disabled={disabled}
|
||||
title={active ? undefined : desc}
|
||||
onClick={() => !disabled && onSelect()}
|
||||
>
|
||||
<span className="frs-opt__led" aria-hidden="true" />
|
||||
<span className="frs-opt__head">
|
||||
<span className="frs-opt__name">{name}</span>
|
||||
{badge && <span className="frs-opt__badge">{badge}</span>}
|
||||
</span>
|
||||
<span className="frs-opt__desc">{desc}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function FirstRunSetup() {
|
||||
const { t } = useTranslation();
|
||||
const locale = useAppStore((s) => s.locale);
|
||||
const setLocale = useAppStore((s) => s.setLocale);
|
||||
|
||||
const [setup, setSetup] = useState(null); // get_setup_state payload
|
||||
const [plan, setPlan] = useState(null); // user's editable choices
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [serverError, setServerError] = useState(null);
|
||||
const mounted = useRef(true);
|
||||
useEffect(() => () => { mounted.current = false; }, []);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const s = await invoke('get_setup_state');
|
||||
if (!mounted.current) return;
|
||||
setSetup(s);
|
||||
setPlan({
|
||||
installMode: s.portable.available && s.defaults.installMode === 'portable' ? 'portable' : 'installed',
|
||||
envDir: s.defaults.envDir,
|
||||
dataDir: s.defaults.dataDir,
|
||||
modelsDir: s.defaults.modelsDir,
|
||||
region: s.defaults.region,
|
||||
updateChannel: s.defaults.updateChannel,
|
||||
// Pre-select ROCm when the machine looks AMD — detection is shown
|
||||
// on the card, and the user can always flip back to Auto.
|
||||
torchVariant: s.hardware?.kind === 'rocm' ? 'rocm' : s.defaults.torchVariant,
|
||||
mirrors: { pypiIndex: '', hfEndpoint: '', pythonDownloads: '' },
|
||||
});
|
||||
} catch (e) {
|
||||
if (mounted.current) setServerError(String(e));
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const portable = plan?.installMode === 'portable';
|
||||
const req = setup?.requirements;
|
||||
const hw = setup?.hardware;
|
||||
const combinedNeed = req ? req.envBytes + req.modelsBytes + req.dataBytes : 0;
|
||||
|
||||
// Live target probes — in portable mode only the anchor folder matters.
|
||||
const portableBase = setup?.portable?.baseDir || '';
|
||||
const envCheck = useTargetCheck(portable ? null : plan?.envDir);
|
||||
const dataCheck = useTargetCheck(portable ? null : plan?.dataDir);
|
||||
const modelsCheck = useTargetCheck(portable ? null : plan?.modelsDir);
|
||||
const portableCheck = useTargetCheck(portable ? portableBase : null);
|
||||
|
||||
// Mirror of the Rust gate: group targets by filesystem, sum requirements,
|
||||
// block when any volume falls short or isn't writable.
|
||||
const blockers = useMemo(() => {
|
||||
if (!plan || !req) return [{ key: 'loading' }];
|
||||
const targets = portable
|
||||
? [{ check: portableCheck, need: combinedNeed, label: portableBase }]
|
||||
: [
|
||||
{ check: envCheck, need: req.envBytes, label: plan.envDir },
|
||||
{ check: dataCheck, need: req.dataBytes, label: plan.dataDir },
|
||||
{ check: modelsCheck, need: req.modelsBytes, label: plan.modelsDir },
|
||||
];
|
||||
if (targets.some((x) => x.check == null)) return [{ key: 'loading' }];
|
||||
const out = [];
|
||||
for (const { check, label } of targets) {
|
||||
if (!check.writable) out.push({ key: 'not_writable', label });
|
||||
}
|
||||
const byFs = new Map();
|
||||
for (const { check, need } of targets) {
|
||||
const k = check.fsKey || check.path;
|
||||
const cur = byFs.get(k) || { need: 0, free: check.freeBytes };
|
||||
cur.need += need;
|
||||
cur.free = Math.min(cur.free ?? Infinity, check.freeBytes ?? Infinity);
|
||||
byFs.set(k, cur);
|
||||
}
|
||||
for (const { need, free } of byFs.values()) {
|
||||
if (free != null && free < need) out.push({ key: 'space', need, free });
|
||||
}
|
||||
return out;
|
||||
}, [plan, req, portable, portableBase, combinedNeed, envCheck, dataCheck, modelsCheck, portableCheck]);
|
||||
|
||||
const pickDir = useCallback(async (field) => {
|
||||
try {
|
||||
const { open } = await import('@tauri-apps/plugin-dialog');
|
||||
const dir = await open({ directory: true, defaultPath: plan?.[field] || undefined });
|
||||
if (typeof dir === 'string' && dir) setPlan((p) => ({ ...p, [field]: dir }));
|
||||
} catch (e) { console.error('folder pick failed', e); }
|
||||
}, [plan]);
|
||||
|
||||
const set = useCallback((patch) => setPlan((p) => ({ ...p, ...patch })), []);
|
||||
|
||||
const start = useCallback(async () => {
|
||||
if (!plan || submitting) return;
|
||||
setSubmitting(true);
|
||||
setServerError(null);
|
||||
try {
|
||||
const clean = (s) => (s && s.trim() ? s.trim() : null);
|
||||
await invoke('complete_setup', {
|
||||
plan: {
|
||||
installMode: plan.installMode,
|
||||
envDir: clean(plan.envDir),
|
||||
dataDir: clean(plan.dataDir),
|
||||
modelsDir: clean(plan.modelsDir),
|
||||
region: plan.region,
|
||||
locale,
|
||||
updateChannel: plan.updateChannel,
|
||||
torchVariant: plan.torchVariant,
|
||||
mirrors: {
|
||||
pypiIndex: clean(plan.mirrors.pypiIndex),
|
||||
hfEndpoint: clean(plan.mirrors.hfEndpoint),
|
||||
pythonDownloads: clean(plan.mirrors.pythonDownloads),
|
||||
},
|
||||
},
|
||||
});
|
||||
// Success: the stage poll in App.jsx leaves `awaiting_setup` and the
|
||||
// normal bootstrap progress UI takes over. Nothing to do here.
|
||||
} catch (e) {
|
||||
if (mounted.current) { setServerError(String(e)); setSubmitting(false); }
|
||||
}
|
||||
}, [plan, submitting, locale]);
|
||||
|
||||
if (!setup || !plan) {
|
||||
return (
|
||||
<div className="frs">
|
||||
<div className="frs__atmo" aria-hidden="true" />
|
||||
<div className="frs__loading">
|
||||
{serverError
|
||||
? <pre className="frs__error">{serverError}</pre>
|
||||
: t('firstrun.loading', 'Preparing setup…')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const blocked = blockers.length > 0;
|
||||
const spaceBlocker = blockers.find((b) => b.key === 'space');
|
||||
// The full machine identity — OS/distro · arch · GPU · CPU · RAM — the
|
||||
// exact matrix cell this install is for (and what bug reports cite).
|
||||
const hwLine = hw
|
||||
? [
|
||||
[hw.osName, hw.arch].filter(Boolean).join(' '),
|
||||
hw.gpu,
|
||||
hw.cpuCores ? `${hw.cpuCores}×CPU` : null,
|
||||
hw.ramGb ? `${hw.ramGb} GB RAM` : null,
|
||||
].filter(Boolean).join(' · ')
|
||||
: null;
|
||||
// ROCm wheels are Linux-only — never offer a choice that can't work on
|
||||
// this platform (Rust clamps it server-side too).
|
||||
const rocmAvailable = setup.os === 'linux';
|
||||
|
||||
return (
|
||||
<div className="frs">
|
||||
<div className="frs__atmo" aria-hidden="true" />
|
||||
<div className="frs__deck">
|
||||
|
||||
{/* ── Masthead: waveform + serif headline + serial plate ────────── */}
|
||||
<header className="frs__mast frs-rise" style={{ '--rise': 0 }} data-tauri-drag-region>
|
||||
<Waveform />
|
||||
{/* Journey rail: this page is stage 1 of the install flow. */}
|
||||
<nav className="frs-wsteps frs-wsteps--journey" aria-label={t('firstrun.title', 'Set up OmniVoice Studio')}>
|
||||
<span className="frs-wstep is-active">
|
||||
<span className="frs-wstep__led" aria-hidden="true" />
|
||||
{t('firstrun.stage_setup', 'Setup')}
|
||||
</span>
|
||||
<span className="frs-wstep">
|
||||
<span className="frs-wstep__led" aria-hidden="true" />
|
||||
{t('firstrun.installing_title', 'Installing')}
|
||||
</span>
|
||||
<span className="frs-wstep">
|
||||
<span className="frs-wstep__led" aria-hidden="true" />
|
||||
{t('firstrun.stage_models', 'Models & engines')}
|
||||
</span>
|
||||
</nav>
|
||||
<div className="frs__mast-row">
|
||||
<div className="frs__mast-text">
|
||||
<h1 className="frs__title">{t('firstrun.title', 'Set up OmniVoice Studio')}</h1>
|
||||
<p className="frs__subtitle">
|
||||
{t('firstrun.subtitle', 'Nothing is installed yet — review where everything goes, then start. You can change these later in Settings.')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="frs__mast-meta">
|
||||
{/* Language + download region live together: the two "where am
|
||||
I" choices, settled before anything else. Custom mirrors
|
||||
hang quietly beneath them, where they belong. */}
|
||||
<div className="frs__mast-selects">
|
||||
<select
|
||||
className="frs-select frs-select--lang"
|
||||
value={locale}
|
||||
onChange={(e) => { setLocale(e.target.value); i18n.changeLanguage(e.target.value); }}
|
||||
aria-label={t('firstrun.language', 'Language')}
|
||||
>
|
||||
{LANGUAGES.map((l) => <option key={l.code} value={l.code}>{l.label}</option>)}
|
||||
</select>
|
||||
<select
|
||||
className="frs-select frs-select--lang"
|
||||
value={plan.region}
|
||||
onChange={(e) => set({ region: e.target.value })}
|
||||
aria-label={t('firstrun.region_label', 'Download region')}
|
||||
>
|
||||
<option value="auto">🌐 {t('bootstrap.auto_detect', 'Auto-detect')}</option>
|
||||
<option value="global">🌐 {t('bootstrap.region_global', 'Global (direct)')}</option>
|
||||
<option value="china">🇨🇳 {t('bootstrap.region_china', 'China (mirror)')}</option>
|
||||
<option value="russia">🇷🇺 {t('bootstrap.region_russia', 'Russia (mirror)')}</option>
|
||||
<option value="restricted">🌍 {t('bootstrap.region_restricted', 'Restricted (mirror)')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<details className="frs__advanced frs__advanced--mast">
|
||||
<summary>{t('firstrun.mirrors_title', 'Custom mirrors (advanced)')}</summary>
|
||||
<div className="frs__mirror-fields">
|
||||
{[
|
||||
['pypiIndex', t('firstrun.mirror_pypi', 'PyPI index URL'), 'https://mirrors.aliyun.com/pypi/simple/'],
|
||||
['hfEndpoint', t('firstrun.mirror_hf', 'Hugging Face endpoint'), 'https://hf-mirror.com'],
|
||||
['pythonDownloads', t('firstrun.mirror_python', 'Python downloads mirror'), 'https://gh-proxy.com/…'],
|
||||
].map(([field, label, ph]) => (
|
||||
<label key={field} className="frs-field">
|
||||
<span>{label}</span>
|
||||
<input
|
||||
className="frs-input"
|
||||
type="url"
|
||||
placeholder={ph}
|
||||
value={plan.mirrors[field]}
|
||||
onChange={(e) => set({ mirrors: { ...plan.mirrors, [field]: e.target.value } })}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* ── Wide deck: storage rail (left) + decision rail (right) ────── */}
|
||||
<div className="frs__grid">
|
||||
<div className="frs__col frs__col--main">
|
||||
|
||||
<Panel title={t('firstrun.mode_title', 'Install mode')} delay={1}>
|
||||
<div className="frs__options frs__options--two" role="radiogroup" aria-label={t('firstrun.mode_title', 'Install mode')} onKeyDown={(e) => radioGroupNav(e, setup.portable.available ? ['installed', 'portable'] : ['installed'], plan.installMode, (v) => set({ installMode: v }))}>
|
||||
<OptionCard
|
||||
active={!portable}
|
||||
onSelect={() => set({ installMode: 'installed' })}
|
||||
name={t('firstrun.mode_installed', 'Installed')}
|
||||
desc={t('firstrun.mode_installed_desc', 'Uses standard system folders. Recommended for most users.')}
|
||||
/>
|
||||
<OptionCard
|
||||
active={portable}
|
||||
disabled={!setup.portable.available}
|
||||
onSelect={() => set({ installMode: 'portable' })}
|
||||
name={t('firstrun.mode_portable', 'Portable')}
|
||||
desc={setup.portable.available
|
||||
? t('firstrun.mode_portable_desc', 'Everything lives in one folder next to the app — move it to another disk or machine as a unit.')
|
||||
: t('firstrun.mode_portable_unavailable', 'Unavailable: the folder next to the app is not writable.')}
|
||||
/>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel title={t('firstrun.storage_title', 'Storage')} delay={2}>
|
||||
{portable ? (
|
||||
<StorageRow
|
||||
label={t('firstrun.portable_folder', 'Portable folder')}
|
||||
desc={t('firstrun.portable_folder_desc', 'App environment, models, and your voice data — one folder, fully movable.')}
|
||||
path={portableBase}
|
||||
need={combinedNeed}
|
||||
check={portableCheck}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<StorageRow
|
||||
label={t('firstrun.env_dir', 'App environment')}
|
||||
desc={t('firstrun.env_dir_desc', 'Python runtime and AI libraries.')}
|
||||
path={plan.envDir}
|
||||
need={req.envBytes}
|
||||
check={envCheck}
|
||||
onPick={() => pickDir('envDir')}
|
||||
/>
|
||||
<StorageRow
|
||||
label={t('firstrun.data_dir', 'Voice data & projects')}
|
||||
desc={t('firstrun.data_dir_desc', 'Your voices, dubs, outputs and project database.')}
|
||||
path={plan.dataDir}
|
||||
need={req.dataBytes}
|
||||
check={dataCheck}
|
||||
onPick={() => pickDir('dataDir')}
|
||||
/>
|
||||
<StorageRow
|
||||
label={t('firstrun.models_dir', 'Model cache')}
|
||||
desc={t('firstrun.models_dir_desc', 'Downloaded AI models — the largest and most relocatable part.')}
|
||||
path={plan.modelsDir}
|
||||
need={req.modelsBytes}
|
||||
check={modelsCheck}
|
||||
onPick={() => pickDir('modelsDir')}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="frs__col frs__col--side">
|
||||
|
||||
<Panel title={t('firstrun.compute_title', 'Compute')} delay={2}>
|
||||
{hwLine && (
|
||||
<div className="frs__hw" title={hwLine}>
|
||||
<span className="frs__hw-dot" aria-hidden="true" />
|
||||
<span className="frs__hw-label">
|
||||
{t('firstrun.compute_detected', { defaultValue: 'Detected' })}
|
||||
</span>
|
||||
<span className="frs__hw-value">{hwLine}</span>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="frs__options"
|
||||
role="radiogroup"
|
||||
aria-label={t('firstrun.compute_title', 'Compute')}
|
||||
onKeyDown={(e) => radioGroupNav(e, rocmAvailable ? ['auto', 'rocm'] : ['auto'], plan.torchVariant, (v) => set({ torchVariant: v }))}
|
||||
>
|
||||
<OptionCard
|
||||
compact
|
||||
active={plan.torchVariant === 'auto'}
|
||||
onSelect={() => set({ torchVariant: 'auto' })}
|
||||
name={t('firstrun.compute_auto', 'Auto (NVIDIA CUDA / Apple MPS / CPU)')}
|
||||
desc={t('firstrun.compute_auto_desc', 'Picks the best backend on this machine at runtime — CUDA on NVIDIA, MPS on Apple Silicon, CPU otherwise.')}
|
||||
badge={hw?.kind === 'cuda' || hw?.kind === 'mps'
|
||||
? t('firstrun.compute_match', { defaultValue: 'matches this machine' })
|
||||
: null}
|
||||
/>
|
||||
{rocmAvailable && (
|
||||
<OptionCard
|
||||
compact
|
||||
active={plan.torchVariant === 'rocm'}
|
||||
onSelect={() => set({ torchVariant: 'rocm' })}
|
||||
name={t('firstrun.compute_rocm', 'AMD GPU (ROCm, Linux)')}
|
||||
desc={t('firstrun.compute_rocm_desc', 'Installs PyTorch ROCm wheels for AMD graphics cards on Linux. Leave on Auto if unsure.')}
|
||||
badge={hw?.kind === 'rocm'
|
||||
? t('firstrun.compute_match', { defaultValue: 'matches this machine' })
|
||||
: null}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel title={t('firstrun.channel_label', 'Update channel')} delay={3}>
|
||||
<div
|
||||
className="frs__options"
|
||||
role="radiogroup"
|
||||
aria-label={t('firstrun.channel_label', 'Update channel')}
|
||||
onKeyDown={(e) => radioGroupNav(e, ['stable', 'preview'], plan.updateChannel, (v) => set({ updateChannel: v }))}
|
||||
>
|
||||
<OptionCard
|
||||
compact
|
||||
active={plan.updateChannel === 'stable'}
|
||||
onSelect={() => set({ updateChannel: 'stable' })}
|
||||
name={t('firstrun.channel_stable', 'Stable')}
|
||||
desc={t('firstrun.channel_stable_desc', 'Tested releases only — updates arrive after community validation.')}
|
||||
/>
|
||||
<OptionCard
|
||||
compact
|
||||
active={plan.updateChannel === 'preview'}
|
||||
onSelect={() => set({ updateChannel: 'preview' })}
|
||||
name={t('firstrun.channel_preview', 'Preview (latest main)')}
|
||||
desc={t('firstrun.channel_preview_desc', 'Rolling builds from the latest main — new engines and fixes first, occasional rough edges.')}
|
||||
/>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Footer: gate + arm ────────────────────────────────────────── */}
|
||||
<footer className="frs__foot frs-rise" style={{ '--rise': 5 }}>
|
||||
{serverError && <pre className="frs__error">{serverError}</pre>}
|
||||
{spaceBlocker && (
|
||||
<p className="frs__blocker">
|
||||
{t('firstrun.insufficient_space', {
|
||||
need: fmtGB(spaceBlocker.need),
|
||||
free: fmtGB(spaceBlocker.free),
|
||||
defaultValue: 'Not enough free space: this layout needs ~{{need}} on one disk, only {{free}} available. Pick a different location.',
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{blockers.some((b) => b.key === 'not_writable') && (
|
||||
<p className="frs__blocker">
|
||||
{t('firstrun.blocked_not_writable', 'A chosen folder is not writable — pick a different location.')}
|
||||
</p>
|
||||
)}
|
||||
<div className="frs__foot-row">
|
||||
<span className="frs__totals">
|
||||
<span className="frs__plate">OVS · v{APP_VERSION}</span>
|
||||
<span className="frs__totals-sep" aria-hidden="true">—</span>
|
||||
{t('firstrun.total_required', {
|
||||
size: fmtGB(combinedNeed),
|
||||
defaultValue: 'Total disk needed: ~{{size}} (one-time download on first use)',
|
||||
})}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={`frs-btn frs-btn--primary ${!blocked && !submitting ? 'is-armed' : ''}`}
|
||||
disabled={blocked || submitting}
|
||||
onClick={start}
|
||||
>
|
||||
<span className="frs-btn__led" aria-hidden="true" />
|
||||
{submitting
|
||||
? t('firstrun.starting', 'Starting…')
|
||||
: t('firstrun.start', 'Start installation')}
|
||||
</button>
|
||||
</div>
|
||||
{/* The product's whole thesis, said where the user decides. */}
|
||||
<p className="frs__trust">
|
||||
{t('firstrun.trust_line', 'Everything runs and stays on this machine — no account, no cloud, no telemetry.')}
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -457,6 +457,14 @@ export default function LogsFooter() {
|
||||
className={`logs-footer__notif-item logs-footer__notif-item--${notif.level} ${notif.action ? 'logs-footer__notif-item--clickable' : ''}`}
|
||||
onClick={() => {
|
||||
if (!notif.action) return;
|
||||
// Acting on the crash notice acknowledges it — the backend
|
||||
// stores the seen crash-log size so it doesn't re-fire
|
||||
// every session until a NEW crash grows the log.
|
||||
if (notif.id === 'crash-last-session') {
|
||||
import('../api/client')
|
||||
.then(({ API }) => fetch(`${API}/system/crash/ack`, { method: 'POST' }))
|
||||
.catch(() => {});
|
||||
}
|
||||
if (notif.action.type === 'navigate') {
|
||||
useAppStore.getState().setMode?.(notif.action.target);
|
||||
setCollapsed(true);
|
||||
|
||||
@@ -8,73 +8,19 @@
|
||||
* never POST to GitHub directly, and never bypass the user's review —
|
||||
* opt-in by construction, no separate consent dialog needed.
|
||||
*
|
||||
* What gets captured (no secrets):
|
||||
* - OS + arch
|
||||
* - OmniVoice version (Vite injects __APP_VERSION__ at build time)
|
||||
* - Browser/webview UA
|
||||
* - Active TTS engine (best-effort fetch)
|
||||
* - Optional user-typed description
|
||||
*
|
||||
* What gets stripped:
|
||||
* - $HOME path → ~/
|
||||
* - Anything matching /TOKEN|KEY|SECRET/i in env vars
|
||||
* - Audio file contents (we don't include them)
|
||||
* Capture + scrubbing live in utils/bugReport.js (shared with the
|
||||
* ErrorBoundary's report action and error toasts): version, OS, GPU/CPU/
|
||||
* RAM, active TTS engine — home paths and credential-shaped strings are
|
||||
* redacted, audio contents never included.
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { Bug } from 'lucide-react';
|
||||
import { Button } from '../ui';
|
||||
import { openExternal } from '../api/external';
|
||||
import { API } from '../api/client';
|
||||
import { buildBugReportUrl } from '../utils/bugReport';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const APP_VERSION = (typeof __APP_VERSION__ !== 'undefined' && __APP_VERSION__) || 'unknown';
|
||||
|
||||
const ISSUES_URL = 'https://github.com/debpalash/OmniVoice-Studio/issues/new';
|
||||
|
||||
function stripHome(s) {
|
||||
if (!s) return s;
|
||||
// Best-effort home redaction — works for the most common /Users/<name>/
|
||||
// and /home/<name>/ paths. We don't know the actual $HOME from JS, so
|
||||
// pattern-match the prefix.
|
||||
return String(s)
|
||||
.replace(/\/Users\/[^/]+/g, '~')
|
||||
.replace(/\/home\/[^/]+/g, '~')
|
||||
.replace(/[A-Z]:\\Users\\[^\\]+/g, '~');
|
||||
}
|
||||
|
||||
async function captureContext() {
|
||||
const lines = [
|
||||
`**Version:** \`${APP_VERSION}\``,
|
||||
`**Platform:** \`${navigator?.userAgent || 'unknown'}\``,
|
||||
];
|
||||
|
||||
// Best-effort backend system info — silently skip if backend is down.
|
||||
try {
|
||||
const r = await fetch(`${API}/system/info`);
|
||||
if (r.ok) {
|
||||
const j = await r.json();
|
||||
// /system/info exposes `platform` (sys.platform) + `device` (best
|
||||
// compute device). Map to those — older field names (os/torch_device/
|
||||
// gpu) never existed on this endpoint, so they silently dropped.
|
||||
if (j?.platform) lines.push(`**OS:** \`${j.platform}\``);
|
||||
if (j?.python) lines.push(`**Python:** \`${j.python}\``);
|
||||
if (j?.device) lines.push(`**Compute device:** \`${stripHome(j.device)}\``);
|
||||
}
|
||||
} catch { /* backend probably not up yet */ }
|
||||
|
||||
try {
|
||||
const r = await fetch(`${API}/engines`);
|
||||
if (r.ok) {
|
||||
const j = await r.json();
|
||||
const active = j?.tts?.active;
|
||||
if (active) lines.push(`**Active TTS engine:** \`${active}\``);
|
||||
}
|
||||
} catch { /* noop */ }
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export default function ReportBugButton({ size = 'sm', variant = 'subtle', label }) {
|
||||
export default function ReportBugButton({ size = 'sm', variant = 'subtle', label, error }) {
|
||||
const { t } = useTranslation();
|
||||
const displayLabel = label || t('reportBug.label');
|
||||
const [building, setBuilding] = useState(false);
|
||||
@@ -82,27 +28,7 @@ export default function ReportBugButton({ size = 'sm', variant = 'subtle', label
|
||||
const handleClick = async () => {
|
||||
setBuilding(true);
|
||||
try {
|
||||
const ctx = await captureContext();
|
||||
const body = [
|
||||
'<!-- Click Submit at the bottom of this page to file the issue.',
|
||||
' Review the auto-captured environment info below and add anything',
|
||||
' about what you were doing when the bug happened. -->',
|
||||
'',
|
||||
'## Describe the bug',
|
||||
'',
|
||||
'<!-- e.g. "Synthesize failed in Design mode after picking Narrator personality" -->',
|
||||
'',
|
||||
'## Environment',
|
||||
'',
|
||||
ctx,
|
||||
'',
|
||||
'## What I was doing',
|
||||
'',
|
||||
'<!-- step-by-step would help us reproduce -->',
|
||||
'',
|
||||
].join('\n');
|
||||
const url = `${ISSUES_URL}?title=${encodeURIComponent('[Bug] ')}&labels=${encodeURIComponent('bug')}&body=${encodeURIComponent(body)}`;
|
||||
await openExternal(url);
|
||||
await openExternal(await buildBugReportUrl({ error }));
|
||||
} finally {
|
||||
setBuilding(false);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* WizardLibrary — the first-run "stock the studio" act as ONE unified list.
|
||||
*
|
||||
* Models and engines are different things (weights vs backends), but the
|
||||
* user's question is singular — "what do I need to get?" — so every
|
||||
* installable is a row of the same grammar:
|
||||
*
|
||||
* LED · name · chip (required / engine / optional) · size · one action
|
||||
*
|
||||
* Required models lead (they gate the wizard's continue), the TTS engines
|
||||
* follow (Use = switch, heavy installs deferred to Settings), and the long
|
||||
* tail of optional models folds behind a quiet count. Live download
|
||||
* progress rides the same SSE stream the Settings model store uses; the
|
||||
* full management surface (search, HF token, deletes) stays in Settings —
|
||||
* a first run needs a checklist, not a store.
|
||||
*/
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useModels, useInstallModel } from '../api/hooks';
|
||||
import { setupDownloadStreamUrl } from '../api/setup';
|
||||
import { listEngines, selectEngine } from '../api/engines';
|
||||
|
||||
const fmtGB = (gb) => (gb == null ? '' : `${gb.toFixed(gb < 10 ? 1 : 0)} GB`);
|
||||
|
||||
/** Aggregate one repo's SSE file events: percent done + ETA from rates. */
|
||||
function aggregate(files) {
|
||||
let done = 0;
|
||||
let total = 0;
|
||||
let rate = 0;
|
||||
for (const f of Object.values(files)) {
|
||||
done += f.downloaded || 0;
|
||||
total += f.total || 0;
|
||||
if ((f.total || 0) > (f.downloaded || 0)) rate += f.rate || 0;
|
||||
}
|
||||
const pct = total > 0 ? Math.min(100, Math.round((done / total) * 100)) : null;
|
||||
const etaSec = rate > 0 && total > done ? (total - done) / rate : null;
|
||||
return { pct, etaSec };
|
||||
}
|
||||
|
||||
function formatEta(seconds) {
|
||||
if (!Number.isFinite(seconds) || seconds <= 0) return '';
|
||||
if (seconds < 60) return '<1m';
|
||||
return `${Math.round(seconds / 60)}m`;
|
||||
}
|
||||
|
||||
function Row({ led, name, chip, chipTone, size, action, sub }) {
|
||||
return (
|
||||
<div className="frs-row swiz-lib__row">
|
||||
<span className={`swiz-lib__led swiz-lib__led--${led}`} aria-hidden="true" />
|
||||
<div className="frs-row__text">
|
||||
<span className="frs-row__label">
|
||||
{name}
|
||||
{chip && <span className={`frs-opt__badge swiz-lib__chip swiz-lib__chip--${chipTone}`}>{chip}</span>}
|
||||
</span>
|
||||
{sub && <span className="swiz-lib__sub">{sub}</span>}
|
||||
</div>
|
||||
<span className="frs-row__readout">{size}</span>
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function WizardLibrary() {
|
||||
const { t } = useTranslation();
|
||||
const modelsQuery = useModels();
|
||||
const installMutation = useInstallModel();
|
||||
const [engines, setEngines] = useState(null);
|
||||
const [progress, setProgress] = useState({}); // { repo_id: { phase, files } }
|
||||
const [showTail, setShowTail] = useState(false);
|
||||
const [switching, setSwitching] = useState(null);
|
||||
const esRef = useRef(null);
|
||||
|
||||
const models = useMemo(() => {
|
||||
const list = modelsQuery.data;
|
||||
return Array.isArray(list) ? list : (list?.models ?? []);
|
||||
}, [modelsQuery.data]);
|
||||
|
||||
// Engines: TTS family only on first run — the family the studio speaks with.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const all = await listEngines();
|
||||
if (!cancelled) setEngines(all?.tts ?? null);
|
||||
} catch { /* backend mid-boot — the wizard polls models anyway */ }
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
// One SSE stream for all rows (same channel the Settings store uses).
|
||||
useEffect(() => {
|
||||
const es = new EventSource(setupDownloadStreamUrl());
|
||||
esRef.current = es;
|
||||
es.onmessage = (evt) => {
|
||||
try {
|
||||
const ev = JSON.parse(evt.data);
|
||||
if (!ev?.repo_id) return;
|
||||
setProgress((prev) => {
|
||||
const cur = prev[ev.repo_id] || { phase: 'active', files: {} };
|
||||
if (ev.phase === 'install_start') return { ...prev, [ev.repo_id]: { phase: 'active', files: {} } };
|
||||
if (ev.phase === 'install_done' || ev.phase === 'install_error') {
|
||||
if (ev.phase === 'install_done') modelsQuery.refetch();
|
||||
const next = { ...prev };
|
||||
delete next[ev.repo_id];
|
||||
return next;
|
||||
}
|
||||
if (!ev.filename) return prev;
|
||||
const files = { ...cur.files, [ev.filename]: { downloaded: ev.downloaded || 0, total: ev.total || 0, rate: ev.rate || 0 } };
|
||||
return { ...prev, [ev.repo_id]: { ...cur, files } };
|
||||
});
|
||||
} catch { /* keepalive */ }
|
||||
};
|
||||
return () => es.close();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const install = (repoId) => {
|
||||
setProgress((p) => ({ ...p, [repoId]: { phase: 'active', files: {} } }));
|
||||
installMutation.mutate(repoId, {
|
||||
onError: (e) => {
|
||||
toast.error(e?.message || 'install failed');
|
||||
setProgress((p) => { const n = { ...p }; delete n[repoId]; return n; });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const useEngine = async (id) => {
|
||||
setSwitching(id);
|
||||
try {
|
||||
const r = await selectEngine('tts', id);
|
||||
setEngines((e) => (e ? { ...e, active: r.active } : e));
|
||||
} catch (e) {
|
||||
toast.error(e?.message || 'switch failed');
|
||||
} finally {
|
||||
setSwitching(null);
|
||||
}
|
||||
};
|
||||
|
||||
const supported = models.filter((m) => m.supported !== false);
|
||||
const required = supported.filter((m) => m.required);
|
||||
const optional = supported.filter((m) => !m.required);
|
||||
|
||||
const modelRow = (m, chip, chipTone) => {
|
||||
const p = progress[m.repo_id];
|
||||
const { pct, etaSec } = p ? aggregate(p.files) : { pct: null, etaSec: null };
|
||||
const downloading = !!p;
|
||||
return (
|
||||
<Row
|
||||
key={m.repo_id}
|
||||
led={m.installed ? 'ok' : downloading ? 'busy' : 'off'}
|
||||
name={m.label}
|
||||
chip={chip}
|
||||
chipTone={chipTone}
|
||||
size={fmtGB(m.size_gb)}
|
||||
sub={downloading ? (
|
||||
<span className="swiz-lib__bar"><span style={{ width: `${pct ?? 4}%` }} /></span>
|
||||
) : null}
|
||||
action={m.installed ? (
|
||||
<span className="swiz-lib__state">✓</span>
|
||||
) : downloading ? (
|
||||
<span className="swiz-lib__state swiz-lib__state--busy">
|
||||
{pct != null ? `${pct}%` : t('firstrun.lib_downloading', 'downloading…')}
|
||||
{etaSec != null && ` · ${t('firstrun.eta_left', { eta: formatEta(etaSec), defaultValue: '~{{eta}} left' })}`}
|
||||
</span>
|
||||
) : (
|
||||
<button type="button" className="frs-btn frs-btn--quiet swiz-lib__act" onClick={() => install(m.repo_id)}>
|
||||
{t('firstrun.lib_download', 'Download')}
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="swiz-lib">
|
||||
{required.map((m) => modelRow(m, t('firstrun.chip_required', 'required'), 'req'))}
|
||||
|
||||
{(engines?.backends ?? []).map((b) => (
|
||||
<Row
|
||||
key={b.id}
|
||||
led={b.id === engines.active ? 'active' : b.available ? 'ok' : 'off'}
|
||||
name={b.display_name}
|
||||
chip={t('firstrun.chip_engine', 'engine')}
|
||||
chipTone="eng"
|
||||
size=""
|
||||
action={b.id === engines.active ? (
|
||||
<span className="swiz-lib__state swiz-lib__state--active">{t('firstrun.lib_active', 'active')}</span>
|
||||
) : b.available ? (
|
||||
<button
|
||||
type="button"
|
||||
className="frs-btn frs-btn--quiet swiz-lib__act"
|
||||
disabled={switching === b.id}
|
||||
onClick={() => useEngine(b.id)}
|
||||
>
|
||||
{t('firstrun.lib_use', 'Use')}
|
||||
</button>
|
||||
) : (
|
||||
<span className="swiz-lib__state" title={b.reason || undefined}>
|
||||
{t('firstrun.lib_in_settings', 'install later in Settings')}
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{optional.length > 0 && !showTail && (
|
||||
<button type="button" className="frs-btn frs-btn--quiet swiz-lib__more" onClick={() => setShowTail(true)}>
|
||||
▸ {t('firstrun.lib_show_all', { count: optional.length, defaultValue: 'Show {{count}} optional models' })}
|
||||
</button>
|
||||
)}
|
||||
{showTail && optional.map((m) => modelRow(m, t('firstrun.chip_optional', 'optional'), 'opt'))}
|
||||
{Object.keys(progress).length > 0 && (
|
||||
<p className="frs__trust">
|
||||
{t('firstrun.resume_note', 'Interrupted downloads resume automatically — closing the app is safe.')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,8 @@ import { apiPost } from '../api/client';
|
||||
import { API } from '../api/client';
|
||||
import { playPing, isTauri } from '../utils/media';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { toastErrorWithReport } from '../utils/errorToast';
|
||||
import { addBreadcrumb } from '../utils/breadcrumbs';
|
||||
import i18next from 'i18next';
|
||||
const t = i18next.t.bind(i18next);
|
||||
|
||||
@@ -68,7 +70,11 @@ export default function useDubWorkflow({ loadProjects, loadProfiles, loadDubHist
|
||||
|
||||
// ── SSE: wait for transcription stream ──
|
||||
const _waitForTranscribe = useCallback((jobId, ctrl) => new Promise((resolve, reject) => {
|
||||
const evt = new EventSource(transcribeStreamUrl(jobId));
|
||||
// Read the optional speaker-count hint at stream-open time (#274) so the
|
||||
// user's choice for this job is honoured without threading it through the
|
||||
// three call sites. null → pyannote auto-detect.
|
||||
const numSpeakers = useAppStore.getState().dubNumSpeakers;
|
||||
const evt = new EventSource(transcribeStreamUrl(jobId, numSpeakers));
|
||||
let gotFinal = false;
|
||||
const close = () => { try { evt.close(); } catch {} };
|
||||
const onAbortSignal = () => { close(); reject(Object.assign(new Error('aborted'), { name: 'AbortError' })); };
|
||||
@@ -200,7 +206,7 @@ export default function useDubWorkflow({ loadProjects, loadProfiles, loadDubHist
|
||||
// ── Handlers ──
|
||||
const handleDubUpload = useCallback(async (dubVideoFile) => {
|
||||
if (!dubVideoFile) return;
|
||||
setDubStep('uploading'); setDubError(''); setDubFailure(null); setDubTracks([]); setDubPrepStage('download');
|
||||
addBreadcrumb('dub:upload'); setDubStep('uploading'); setDubError(''); setDubFailure(null); setDubTracks([]); setDubPrepStage('download');
|
||||
setDubPrepProgress({ percent: null, speedBps: null, etaS: null, stageStartedAt: Date.now() });
|
||||
const ctrl = new AbortController();
|
||||
dubAbortCtrlRef.current = ctrl;
|
||||
@@ -225,7 +231,7 @@ export default function useDubWorkflow({ loadProjects, loadProfiles, loadDubHist
|
||||
} catch (err) {
|
||||
setDubPrepStage(null);
|
||||
if (err.name === 'AbortError') { toast(t('dub_workflow.upload_cancelled')); setDubStep('idle'); useAppStore.getState().dismissPill(); }
|
||||
else { setDubError(err.message); setDubStep('idle'); toast.error(t('dub_workflow.upload_failed', { message: err.message })); useAppStore.getState().errorPill(err.message); }
|
||||
else { setDubError(err.message); setDubStep('idle'); toastErrorWithReport(t('dub_workflow.upload_failed', { message: err.message }), err); useAppStore.getState().errorPill(err.message); }
|
||||
setTranscribeStart(null);
|
||||
} finally { dubAbortCtrlRef.current = null; }
|
||||
}, [setDubStep, setDubError, setDubFailure, setDubTracks, setDubPrepStage, setDubJobId, setDubFilename, setDubTaskId, setDubSegments, _waitForPrep, _waitForTranscribe, loadProjects, loadProfiles]);
|
||||
@@ -233,7 +239,7 @@ export default function useDubWorkflow({ loadProjects, loadProfiles, loadDubHist
|
||||
const handleDubIngestUrl = useCallback(async (url, opts = {}) => {
|
||||
const clean = (url || '').trim();
|
||||
if (!clean) return;
|
||||
setDubStep('uploading'); setDubError(''); setDubFailure(null); setDubTracks([]); setDubPrepStage('download');
|
||||
addBreadcrumb('dub:ingest-url'); setDubStep('uploading'); setDubError(''); setDubFailure(null); setDubTracks([]); setDubPrepStage('download');
|
||||
setDubPrepProgress({ percent: null, speedBps: null, etaS: null, stageStartedAt: Date.now() });
|
||||
const ctrl = new AbortController();
|
||||
dubAbortCtrlRef.current = ctrl;
|
||||
@@ -257,7 +263,7 @@ export default function useDubWorkflow({ loadProjects, loadProfiles, loadDubHist
|
||||
} catch (err) {
|
||||
setDubPrepStage(null);
|
||||
if (err.name === 'AbortError') { toast(t('dub_workflow.ingest_cancelled')); setDubStep('idle'); useAppStore.getState().dismissPill(); }
|
||||
else { setDubError(err.message); setDubStep('idle'); toast.error(t('dub_workflow.ingest_failed', { message: err.message })); useAppStore.getState().errorPill(err.message); }
|
||||
else { setDubError(err.message); setDubStep('idle'); toastErrorWithReport(t('dub_workflow.ingest_failed', { message: err.message }), err); useAppStore.getState().errorPill(err.message); }
|
||||
setTranscribeStart(null);
|
||||
} finally { dubAbortCtrlRef.current = null; }
|
||||
}, [setDubStep, setDubError, setDubFailure, setDubTracks, setDubPrepStage, setDubJobId, setDubTaskId, setDubSegments, _waitForPrep, _waitForTranscribe, loadProjects, loadProfiles]);
|
||||
@@ -280,7 +286,7 @@ export default function useDubWorkflow({ loadProjects, loadProfiles, loadDubHist
|
||||
} catch (err) {
|
||||
setTranscribeStart(null);
|
||||
if (err.name === 'AbortError') { toast(t('dub_workflow.retry_cancelled')); setDubStep('idle'); }
|
||||
else { setDubError(err.message); setDubStep('idle'); toast.error(t('dub_workflow.transcription_failed', { message: err.message })); }
|
||||
else { setDubError(err.message); setDubStep('idle'); toastErrorWithReport(t('dub_workflow.transcription_failed', { message: err.message }), err); }
|
||||
} finally { dubAbortCtrlRef.current = null; }
|
||||
}, [dubJobId, setDubError, setDubSegments, setDubStep, _waitForTranscribe, loadProjects]);
|
||||
|
||||
@@ -377,6 +383,7 @@ export default function useDubWorkflow({ loadProjects, loadProfiles, loadDubHist
|
||||
}, [dubSegments, dubLangCode, translateProvider, translateQuality, glossaryTerms, setIsTranslating, setDubSegments, setDubError]);
|
||||
|
||||
const handleDubGenerate = useCallback(async (opts = {}) => {
|
||||
addBreadcrumb('dub:generate');
|
||||
const regenOnly = Array.isArray(opts.regenOnly) && opts.regenOnly.length ? opts.regenOnly : null;
|
||||
const preview = !!opts.preview;
|
||||
setDubStep('generating');
|
||||
|
||||
@@ -6,6 +6,8 @@ import { probeAudioDuration } from '../utils/format';
|
||||
import { CLONE_MAX_SECONDS, PRESETS } from '../utils/constants';
|
||||
import { buildDesignInstruct } from '../utils/voiceInstruct';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { toastErrorWithReport } from '../utils/errorToast';
|
||||
import { addBreadcrumb } from '../utils/breadcrumbs';
|
||||
import i18next from 'i18next';
|
||||
const t = i18next.t.bind(i18next);
|
||||
|
||||
@@ -69,6 +71,7 @@ export default function useTTS({ selectedProfile, setSelectedProfile, loadHistor
|
||||
const handleGenerate = useCallback(async () => {
|
||||
if (!text.trim()) return toast.error(t('tts_errors.enter_text'));
|
||||
if (mode === 'clone' && !refAudio && !selectedProfile) return toast.error(t('tts_errors.upload_or_select'));
|
||||
addBreadcrumb(`generate:start (${mode})`);
|
||||
setIsGenerating(true);
|
||||
setGenerationTime(0);
|
||||
const st = Date.now();
|
||||
@@ -156,10 +159,13 @@ export default function useTTS({ selectedProfile, setSelectedProfile, loadHistor
|
||||
setSidebarTab('history');
|
||||
playPing();
|
||||
} catch (err) {
|
||||
const msg = err?.name === 'AbortError'
|
||||
? t('tts_errors.timeout')
|
||||
: t('tts_errors.error_prefix', { message: err.message });
|
||||
toast.error(msg);
|
||||
// Timeouts are user-recoverable (retry / shorter input) — plain toast.
|
||||
// Real generation failures get the "Report this bug" action.
|
||||
if (err?.name === 'AbortError') {
|
||||
toast.error(t('tts_errors.timeout'));
|
||||
} else {
|
||||
toastErrorWithReport(t('tts_errors.error_prefix', { message: err.message }), err);
|
||||
}
|
||||
} finally {
|
||||
if (abortTimer) clearTimeout(abortTimer);
|
||||
clearInterval(timerRef.current);
|
||||
|
||||
@@ -977,7 +977,7 @@
|
||||
"back": "العودة إلى الاستوديو",
|
||||
"badge": "رخصة تجارية",
|
||||
"hero_title": "شحن أصوات الذكاء الاصطناعي في الإنتاج",
|
||||
"hero_desc": "OmniVoice Studio متاح المصدر بموجب ترخيص المصدر الوظيفي (FSL). يمكن لمعظم المستخدمين التقييم وإنشاء نماذج أولية وحتى النشر داخليًا بدون اتفاقية تجارية. لا تحتاج إلى ترخيص تجاري إلا إذا كنت تقوم ببناء منتج أو خدمة منافسة، أو إذا كانت حالة الاستخدام الخاصة بك تقع خارج حدود FSL.",
|
||||
"hero_desc": "OmniVoice Studio برنامج حر ومفتوح المصدر بموجب رخصة GNU Affero العمومية الإصدار 3 (AGPL-3.0) — مجاني للاستخدام، بما في ذلك الاستخدام التجاري والداخلي في الشركات. لا تحتاج إلى ترخيص تجاري إلا إذا أردت تضمين OmniVoice Studio في منتج أو خدمة مغلقة المصدر أو احتكارية دون التزامات الحقوق المتروكة (copyleft) في AGPL-3.0.",
|
||||
"why_title": "لماذا تختار الشركات OmniVoice",
|
||||
"pricing_title": "التسعير",
|
||||
"faq_title": "الأسئلة الشائعة",
|
||||
@@ -998,7 +998,7 @@
|
||||
"benefit_source_desc": "الرؤية الكاملة في المكدس. التدقيق والشوكة والتكيف ضمن شروط الترخيص.",
|
||||
"benefit_lang": "646 لغة",
|
||||
"benefit_lang_desc": "قم بالنسخ والترجمة والدبلجة عبر 646 لغة بجودة تضاهي المستوى البشري.",
|
||||
"hero_note": "الاستخدام الداخلي - حتى على نطاق واسع - مجاني بموجب قانون FSL؛ الترخيص التجاري مطلوب فقط لتقديم OmniVoice للآخرين كمنتج أو خدمة منافسة (واجهة برمجة تطبيقات مستضافة أو الدفع لكل استخدام، أو تطبيق مُعاد بيعه أو ذو علامة بيضاء). ستتوفر مستويات التسعير قريبًا - تواصل معنا في هذه الأثناء."
|
||||
"hero_note": "الاستخدام والاستضافة الذاتية والاستخدام التجاري كلها مجانية بموجب AGPL-3.0 — حتى على نطاق واسع. AGPL رخصة حقوق متروكة شبكية: إذا عدّلت OmniVoice وقدّمت النسخة المعدّلة للآخرين عبر الشبكة، فعليك مشاركة شيفرتك المصدرية المعدّلة بالشروط نفسها. الترخيص التجاري يرفع هذه الالتزامات عن عمليات النشر الاحتكارية مغلقة المصدر. خطط الأسعار قادمة قريبًا — تواصل معنا في هذه الأثناء."
|
||||
},
|
||||
"exportModal": {
|
||||
"export": "تصدير",
|
||||
@@ -1097,7 +1097,8 @@
|
||||
"script_french": "غير الإنجليزية (الفرنسية)",
|
||||
"aria_pause": "إيقاف مؤقت {{label}}",
|
||||
"aria_hear": "استمع {{label}}",
|
||||
"aria_replay": "إعادة تشغيل {{label}} من خلال الناسخ"
|
||||
"aria_replay": "إعادة تشغيل {{label}} من خلال الناسخ",
|
||||
"dictation_lede_hotkey_only": "اضغط مطوّلًا على الاختصار أعلاه في أي مكان على سطح المكتب وتحدث ثم أفلت — سيظهر النص في التطبيق النشط. اضغطه الآن للتحقق."
|
||||
},
|
||||
"direction": {
|
||||
"title": "اتجاه المقطع #{{id}}",
|
||||
@@ -1411,13 +1412,11 @@
|
||||
},
|
||||
"enterprise_faq": {
|
||||
"q_internal_tools": "هل أحتاج إلى ترخيص للأدوات الداخلية؟",
|
||||
"a_internal_tools": "يعد الاستخدام الداخلي من قبل الموظفين والمقاولين لديك غرضًا مسموحًا به بموجب قانون FSL - ولا يلزم الحصول على ترخيص. يلزم الحصول على ترخيص تجاري عندما تجعل OmniVoice متاحًا للآخرين كجزء من منتج أو خدمة منافسة (إعادة البيع، SaaS المستضافة، العلامة البيضاء).",
|
||||
"a_internal_tools": "لا. استخدام موظفيك ومتعاقديك — بما في ذلك التعديل والاستضافة الذاتية داخليًا — مجاني بموجب AGPL-3.0. لا يلزم الترخيص التجاري إلا إذا ضمّنت OmniVoice في منتج أو خدمة مغلقة المصدر أو احتكارية ولم ترغب في الالتزام بمتطلبات AGPL لمشاركة الشيفرة المصدرية.",
|
||||
"q_try_before": "هل يمكنني المحاولة قبل الالتزام؟",
|
||||
"a_try_before": "نعم. التطبيق الكامل مجاني للتنزيل والتشغيل محليًا للتقييم بموجب FSL. عندما تكون مستعدًا لمناقشة النشر التجاري، راسلنا عبر البريد الإلكتروني وسنعمل على التفاصيل معًا.",
|
||||
"a_try_before": "نعم. التطبيق الكامل مجاني للتنزيل والتشغيل والاستضافة الذاتية بموجب AGPL-3.0 — دون أي اتفاقية. عندما تكون مستعدًا لمناقشة ترخيص تجاري (للاستخدام الاحتكاري)، راسلنا عبر البريد الإلكتروني وسنرتّب التفاصيل معًا.",
|
||||
"q_watermark": "ماذا عن العلامة المائية؟",
|
||||
"a_watermark": "يتم تضمين العلامة المائية AudioSeal غير المرئية بشكل افتراضي. يمكن للمرخصين التجاريين تعطيله في الإعدادات → الخصوصية. يتضمن الاستخدام المجاني/الشخصي دائمًا العلامة المائية.",
|
||||
"q_apache": "هل أصبح المصدر Apache 2.0؟",
|
||||
"a_apache": "نعم. يتحول كل إصدار تلقائيًا إلى ترخيص Apache، الإصدار 2.0 في الذكرى السنوية الثانية لنشره. وهذا يعني أن إصدار اليوم هو Apache 2.0 خلال عامين، ولا يلزم اتخاذ أي إجراء من جانبنا - وتضمن FSL ذلك بشكل لا رجعة فيه."
|
||||
"a_watermark": "العلامة المائية غير المرئية AudioSeal مضمّنة افتراضيًا للجميع. يمكن لحاملي الترخيص التجاري تعطيلها في الإعدادات → الخصوصية."
|
||||
},
|
||||
"gallery_extra": {
|
||||
"save_prompt": "أدخل اسمًا لملف التعريف الصوتي هذا:",
|
||||
@@ -1597,5 +1596,73 @@
|
||||
"none": "لم يتم العثور على أي إصدارات",
|
||||
"load_error": "تعذر تحميل الإصدارات (غير متصل؟)",
|
||||
"retry_load": "أعد المحاولة"
|
||||
},
|
||||
"firstrun": {
|
||||
"loading": "جارٍ تجهيز الإعداد…",
|
||||
"title": "إعداد OmniVoice Studio",
|
||||
"subtitle": "لم يتم تثبيت أي شيء بعد — راجع أماكن حفظ كل شيء ثم ابدأ. يمكنك تغييرها لاحقًا من الإعدادات.",
|
||||
"language": "اللغة",
|
||||
"mode_title": "وضع التثبيت",
|
||||
"mode_installed": "مثبَّت",
|
||||
"mode_installed_desc": "يستخدم مجلدات النظام القياسية. يُنصح به لمعظم المستخدمين.",
|
||||
"mode_portable": "محمول",
|
||||
"mode_portable_desc": "كل شيء في مجلد واحد بجوار التطبيق — انقله كوحدة واحدة إلى قرص أو جهاز آخر.",
|
||||
"mode_portable_unavailable": "غير متاح: المجلد بجوار التطبيق غير قابل للكتابة.",
|
||||
"storage_title": "التخزين",
|
||||
"portable_folder": "المجلد المحمول",
|
||||
"portable_folder_desc": "بيئة التشغيل والنماذج وبيانات صوتك — مجلد واحد قابل للنقل بالكامل.",
|
||||
"env_dir": "بيئة التطبيق",
|
||||
"env_dir_desc": "بيئة Python ومكتبات الذكاء الاصطناعي.",
|
||||
"data_dir": "بيانات الصوت والمشاريع",
|
||||
"data_dir_desc": "أصواتك ودبلجاتك ومخرجاتك وقاعدة بيانات المشاريع.",
|
||||
"models_dir": "ذاكرة النماذج المؤقتة",
|
||||
"models_dir_desc": "نماذج الذكاء الاصطناعي المنزَّلة — الجزء الأكبر والأسهل في النقل.",
|
||||
"needs": "يحتاج ~{{size}}",
|
||||
"free": "متاح {{size}}",
|
||||
"checking": "جارٍ الفحص…",
|
||||
"not_writable": "غير قابل للكتابة",
|
||||
"change": "تغيير…",
|
||||
"compute_title": "المعالجة",
|
||||
"compute_label": "GPU / مسرّع",
|
||||
"compute_auto": "تلقائي (NVIDIA CUDA / Apple MPS / CPU)",
|
||||
"compute_rocm": "بطاقة AMD (ROCm، Linux)",
|
||||
"channel_label": "قناة التحديث",
|
||||
"channel_stable": "مستقرة",
|
||||
"channel_preview": "معاينة (أحدث main)",
|
||||
"network_title": "الشبكة",
|
||||
"region_label": "منطقة التنزيل",
|
||||
"mirrors_title": "مرايا مخصصة (متقدم)",
|
||||
"mirror_pypi": "عنوان فهرس PyPI",
|
||||
"mirror_hf": "نقطة نهاية Hugging Face",
|
||||
"mirror_python": "مرآة تنزيلات Python",
|
||||
"insufficient_space": "المساحة غير كافية: يحتاج هذا التوزيع إلى ~{{need}} على قرص واحد، والمتاح {{free}} فقط. اختر موقعًا آخر.",
|
||||
"blocked_not_writable": "أحد المجلدات المختارة غير قابل للكتابة — اختر موقعًا آخر.",
|
||||
"total_required": "إجمالي المساحة المطلوبة: ~{{size}} (تنزيل لمرة واحدة عند أول استخدام)",
|
||||
"start": "بدء التثبيت",
|
||||
"starting": "جارٍ البدء…",
|
||||
"compute_detected": "تم الاكتشاف",
|
||||
"compute_match": "يطابق هذا الجهاز",
|
||||
"compute_auto_desc": "يختار أفضل واجهة خلفية لهذا الجهاز عند التشغيل — CUDA على NVIDIA وMPS على Apple Silicon وإلا CPU.",
|
||||
"compute_rocm_desc": "يثبّت حزم PyTorch ROCm لبطاقات AMD على Linux. اتركه على تلقائي إن لم تكن متأكدًا.",
|
||||
"channel_stable_desc": "إصدارات مُختبرة فقط — تصل التحديثات بعد تحقق المجتمع.",
|
||||
"channel_preview_desc": "بنى متجددة من أحدث main — محركات وإصلاحات جديدة أولًا، مع خشونة طفيفة أحيانًا.",
|
||||
"installing_title": "جارٍ التثبيت",
|
||||
"activity_title": "النشاط",
|
||||
"stage_setup": "الإعداد",
|
||||
"stage_models": "النماذج والمحركات",
|
||||
"chip_required": "مطلوب",
|
||||
"chip_optional": "اختياري",
|
||||
"chip_engine": "محرك",
|
||||
"lib_download": "تنزيل",
|
||||
"lib_downloading": "جارٍ التنزيل…",
|
||||
"lib_use": "استخدام",
|
||||
"lib_active": "نشط",
|
||||
"lib_in_settings": "ثبّته لاحقًا من الإعدادات",
|
||||
"lib_show_all": "عرض {{count}} نموذجًا اختياريًا",
|
||||
"trust_line": "كل شيء يعمل ويبقى على هذا الجهاز — بلا حساب ولا سحابة ولا قياس عن بُعد.",
|
||||
"resume_note": "تُستأنف التنزيلات المتقطعة تلقائيًا — إغلاق التطبيق آمن.",
|
||||
"eta_left": "متبقٍ ~{{eta}}",
|
||||
"first_sound_text": "مرحبًا بك في الاستوديو الخاص بك. كل كلمة تسمعها وُلِّدت على هذا الجهاز للتو.",
|
||||
"first_sound_done": "ذلك الصوت؟ وُلِّد قبل ثوانٍ، محليًا. مرحبًا بك."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -978,7 +978,7 @@
|
||||
"back": "Zurück zum Studio",
|
||||
"badge": "Kommerzielle Lizenz",
|
||||
"hero_title": "Versenden Sie KI-Stimmen in der Produktion",
|
||||
"hero_desc": "OmniVoice Studio ist als Quelle unter der Functional Source License (FSL) verfügbar. Die meisten Benutzer können ohne kommerzielle Vereinbarung evaluieren, Prototypen erstellen und sogar intern bereitstellen. Sie benötigen eine kommerzielle Lizenz nur, wenn Sie ein konkurrierendes Produkt oder eine konkurrierende Dienstleistung entwickeln oder wenn Ihr Anwendungsfall außerhalb der FSL-Grenzen liegt.",
|
||||
"hero_desc": "OmniVoice Studio ist freie Open-Source-Software unter der GNU Affero General Public License v3 (AGPL-3.0) — kostenlos nutzbar, auch für kommerzielle und interne geschäftliche Zwecke. Eine kommerzielle Lizenz benötigen Sie nur, wenn Sie OmniVoice Studio ohne die Copyleft-Pflichten der AGPL-3.0 in ein Closed-Source- oder proprietäres Produkt oder einen entsprechenden Dienst einbetten möchten.",
|
||||
"why_title": "Warum Unternehmen sich für OmniVoice entscheiden",
|
||||
"pricing_title": "Preise",
|
||||
"faq_title": "Häufige Fragen",
|
||||
@@ -999,7 +999,7 @@
|
||||
"benefit_source_desc": "Volle Sicht auf den Stapel. Prüfen, forken und anpassen Sie innerhalb der Lizenzbedingungen.",
|
||||
"benefit_lang": "646 Sprachen",
|
||||
"benefit_lang_desc": "Transkribieren, übersetzen und synchronisieren Sie in 646 Sprachen mit menschlicher Qualität.",
|
||||
"hero_note": "Die interne Nutzung – auch in großem Umfang – ist im Rahmen der FSL kostenlos; Eine kommerzielle Lizenz ist nur erforderlich, um OmniVoice anderen als Konkurrenzprodukt oder -dienst anzubieten (eine gehostete oder Pay-per-Use-API, eine weiterverkaufte oder White-Label-App). Die Preisstufen folgen bald – nehmen Sie in der Zwischenzeit Kontakt mit uns auf."
|
||||
"hero_note": "Nutzung, Self-Hosting und kommerzielle Nutzung sind unter der AGPL-3.0 kostenlos — auch im großen Maßstab. Die AGPL ist eine Netzwerk-Copyleft-Lizenz: Wenn Sie OmniVoice modifizieren und diese modifizierte Version anderen über ein Netzwerk anbieten, müssen Sie Ihren geänderten Quellcode zu denselben Bedingungen offenlegen. Eine kommerzielle Lizenz hebt diese Copyleft-Pflichten für proprietäre Closed-Source-Deployments auf. Preismodelle folgen in Kürze — melden Sie sich in der Zwischenzeit gerne."
|
||||
},
|
||||
"exportModal": {
|
||||
"export": "Exportieren",
|
||||
@@ -1098,7 +1098,8 @@
|
||||
"script_french": "Nicht-Englisch (Französisch)",
|
||||
"aria_pause": "Pause {{label}}",
|
||||
"aria_hear": "Hören Sie {{label}}",
|
||||
"aria_replay": "Wiederholen Sie {{label}} durch den Transkriptor"
|
||||
"aria_replay": "Wiederholen Sie {{label}} durch den Transkriptor",
|
||||
"dictation_lede_hotkey_only": "Halte das Tastenkürzel oben überall auf dem Desktop gedrückt, sprich und lass los — der Text landet in der fokussierten App. Drücke es jetzt zum Verifizieren."
|
||||
},
|
||||
"direction": {
|
||||
"title": "Richtung für Segment #{{id}}",
|
||||
@@ -1412,13 +1413,11 @@
|
||||
},
|
||||
"enterprise_faq": {
|
||||
"q_internal_tools": "Benötige ich eine Lizenz für interne Tools?",
|
||||
"a_internal_tools": "Die interne Nutzung durch Ihre Mitarbeiter und Auftragnehmer ist ein zulässiger Zweck gemäß FSL – es ist keine Lizenz erforderlich. Eine kommerzielle Lizenz ist erforderlich, wenn Sie OmniVoice anderen als Teil eines konkurrierenden Produkts oder einer konkurrierenden Dienstleistung (Weiterverkauf, gehostetes SaaS, White-Label) zur Verfügung stellen.",
|
||||
"a_internal_tools": "Nein. Die Nutzung durch Ihre Mitarbeitenden und Auftragnehmer — einschließlich interner Modifikation und internem Self-Hosting — ist unter der AGPL-3.0 kostenlos. Eine kommerzielle Lizenz ist nur nötig, wenn Sie OmniVoice in ein Closed-Source- oder proprietäres Produkt oder einen Dienst einbetten und die Quelloffenlegungspflichten der AGPL nicht erfüllen möchten.",
|
||||
"q_try_before": "Kann ich es versuchen, bevor ich mich verpflichte?",
|
||||
"a_try_before": "Ja. Die vollständige App kann kostenlos heruntergeladen und zur Evaluierung im Rahmen der FSL lokal ausgeführt werden. Wenn Sie bereit sind, eine kommerzielle Bereitstellung zu besprechen, senden Sie uns eine E-Mail und wir besprechen gemeinsam die Details.",
|
||||
"a_try_before": "Ja. Die vollständige App lässt sich unter der AGPL-3.0 kostenlos herunterladen, ausführen und selbst hosten — ganz ohne Vertrag. Wenn Sie über eine kommerzielle Lizenz (für proprietäre Nutzung) sprechen möchten, schreiben Sie uns eine E-Mail und wir klären die Details gemeinsam.",
|
||||
"q_watermark": "Was ist mit dem Wasserzeichen?",
|
||||
"a_watermark": "Das unsichtbare AudioSeal-Wasserzeichen ist standardmäßig eingebettet. Kommerzielle Lizenznehmer können es unter Einstellungen → Datenschutz deaktivieren. Die kostenlose/persönliche Nutzung beinhaltet immer das Wasserzeichen.",
|
||||
"q_apache": "Wird die Quelle jemals Apache 2.0?",
|
||||
"a_apache": "Ja. Jede Veröffentlichung wird am zweiten Jahrestag ihrer Veröffentlichung automatisch auf die Apache-Lizenz, Version 2.0, konvertiert. Das bedeutet, dass die heutige Veröffentlichung Apache 2.0 in zwei Jahren ist, ohne dass wir etwas unternehmen müssen – die FSL garantiert dies unwiderruflich."
|
||||
"a_watermark": "Das unsichtbare AudioSeal-Wasserzeichen ist standardmäßig für alle eingebettet. Kommerzielle Lizenznehmer können es unter Einstellungen → Datenschutz deaktivieren."
|
||||
},
|
||||
"gallery_extra": {
|
||||
"save_prompt": "Geben Sie einen Namen für dieses Sprachprofil ein:",
|
||||
@@ -1598,5 +1597,73 @@
|
||||
"none": "Keine Veröffentlichungen gefunden",
|
||||
"load_error": "Veröffentlichungen konnten nicht geladen werden (offline?)",
|
||||
"retry_load": "Versuchen Sie es noch einmal"
|
||||
},
|
||||
"firstrun": {
|
||||
"loading": "Einrichtung wird vorbereitet…",
|
||||
"title": "OmniVoice Studio einrichten",
|
||||
"subtitle": "Noch ist nichts installiert – prüfe, wo alles gespeichert wird, und starte dann. Später in den Einstellungen änderbar.",
|
||||
"language": "Sprache",
|
||||
"mode_title": "Installationsmodus",
|
||||
"mode_installed": "Installiert",
|
||||
"mode_installed_desc": "Nutzt die Standard-Systemordner. Für die meisten empfohlen.",
|
||||
"mode_portable": "Portabel",
|
||||
"mode_portable_desc": "Alles liegt in einem Ordner neben der App – als Einheit auf andere Laufwerke oder Rechner verschiebbar.",
|
||||
"mode_portable_unavailable": "Nicht verfügbar: Der Ordner neben der App ist nicht beschreibbar.",
|
||||
"storage_title": "Speicher",
|
||||
"portable_folder": "Portabler Ordner",
|
||||
"portable_folder_desc": "Laufzeitumgebung, Modelle und deine Sprachdaten – ein Ordner, komplett verschiebbar.",
|
||||
"env_dir": "App-Umgebung",
|
||||
"env_dir_desc": "Python-Runtime und KI-Bibliotheken.",
|
||||
"data_dir": "Sprachdaten & Projekte",
|
||||
"data_dir_desc": "Deine Stimmen, Synchronisationen, Ausgaben und die Projektdatenbank.",
|
||||
"models_dir": "Modell-Cache",
|
||||
"models_dir_desc": "Heruntergeladene KI-Modelle – der größte und am leichtesten verlagerbare Teil.",
|
||||
"needs": "benötigt ~{{size}}",
|
||||
"free": "{{size}} frei",
|
||||
"checking": "prüfe…",
|
||||
"not_writable": "nicht beschreibbar",
|
||||
"change": "Ändern…",
|
||||
"compute_title": "Rechenleistung",
|
||||
"compute_label": "GPU / Beschleuniger",
|
||||
"compute_auto": "Auto (NVIDIA CUDA / Apple MPS / CPU)",
|
||||
"compute_rocm": "AMD-GPU (ROCm, Linux)",
|
||||
"channel_label": "Update-Kanal",
|
||||
"channel_stable": "Stabil",
|
||||
"channel_preview": "Vorschau (aktueller main)",
|
||||
"network_title": "Netzwerk",
|
||||
"region_label": "Download-Region",
|
||||
"mirrors_title": "Eigene Mirrors (erweitert)",
|
||||
"mirror_pypi": "PyPI-Index-URL",
|
||||
"mirror_hf": "Hugging-Face-Endpoint",
|
||||
"mirror_python": "Python-Download-Mirror",
|
||||
"insufficient_space": "Zu wenig Speicherplatz: Dieses Layout braucht ~{{need}} auf einem Laufwerk, nur {{free}} verfügbar. Wähle einen anderen Ort.",
|
||||
"blocked_not_writable": "Ein gewählter Ordner ist nicht beschreibbar – wähle einen anderen Ort.",
|
||||
"total_required": "Benötigter Speicher insgesamt: ~{{size}} (einmaliger Download beim ersten Start)",
|
||||
"start": "Installation starten",
|
||||
"starting": "Starte…",
|
||||
"compute_detected": "Erkannt",
|
||||
"compute_match": "passt zu diesem Rechner",
|
||||
"compute_auto_desc": "Wählt zur Laufzeit das beste Backend dieses Rechners — CUDA auf NVIDIA, MPS auf Apple Silicon, sonst CPU.",
|
||||
"compute_rocm_desc": "Installiert PyTorch-ROCm-Wheels für AMD-Grafikkarten unter Linux. Im Zweifel auf Auto lassen.",
|
||||
"channel_stable_desc": "Nur getestete Releases — Updates kommen nach Community-Validierung.",
|
||||
"channel_preview_desc": "Rollende Builds vom neuesten main — neue Engines und Fixes zuerst, gelegentlich kleine Kanten.",
|
||||
"installing_title": "Installation",
|
||||
"activity_title": "Aktivität",
|
||||
"stage_setup": "Einrichtung",
|
||||
"stage_models": "Modelle & Engines",
|
||||
"chip_required": "erforderlich",
|
||||
"chip_optional": "optional",
|
||||
"chip_engine": "Engine",
|
||||
"lib_download": "Herunterladen",
|
||||
"lib_downloading": "lädt…",
|
||||
"lib_use": "Verwenden",
|
||||
"lib_active": "aktiv",
|
||||
"lib_in_settings": "später in den Einstellungen",
|
||||
"lib_show_all": "{{count}} optionale Modelle anzeigen",
|
||||
"trust_line": "Alles läuft und bleibt auf diesem Rechner — kein Konto, keine Cloud, keine Telemetrie.",
|
||||
"resume_note": "Unterbrochene Downloads werden automatisch fortgesetzt — die App zu schließen ist sicher.",
|
||||
"eta_left": "noch ~{{eta}}",
|
||||
"first_sound_text": "Willkommen in deinem Studio. Jedes Wort, das du hörst, wurde gerade eben auf diesem Rechner erzeugt.",
|
||||
"first_sound_done": "Diese Stimme? Vor Sekunden lokal erzeugt. Willkommen."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -271,6 +271,16 @@
|
||||
},
|
||||
"about": {
|
||||
"app": "App",
|
||||
"self_check": "Run self-check",
|
||||
"self_check_failed": "Self-check failed: {{message}}",
|
||||
"self_check_ok": "OK",
|
||||
"self_check_warn": "Warning",
|
||||
"self_check_fail": "Failed",
|
||||
"self_check_healthy": "All checks passed — this install looks healthy.",
|
||||
"self_check_attention": "{{count}} check(s) failed — see the hints above.",
|
||||
"save_bundle": "Save diagnostic bundle",
|
||||
"bundle_saved": "Diagnostic bundle saved: {{filename}}",
|
||||
"bundle_failed": "Could not build the diagnostic bundle: {{message}}",
|
||||
"version": "Version",
|
||||
"tauri_runtime": "Tauri runtime",
|
||||
"platform": "Platform",
|
||||
@@ -450,6 +460,9 @@
|
||||
"export_wav": "WAV",
|
||||
"export_srt": "SRT",
|
||||
"upload_transcribe": "Upload & Transcribe",
|
||||
"num_speakers_label": "Speakers",
|
||||
"num_speakers_auto": "Auto",
|
||||
"num_speakers_help": "How many speakers are in this video? Leave blank to auto-detect. Set a number if auto-detect merges multiple speakers into one.",
|
||||
"multi_lang": "Multi-lang",
|
||||
"prep_download": "Downloading video…",
|
||||
"prep_extract": "Extracting audio…",
|
||||
@@ -877,8 +890,8 @@
|
||||
"back": "Back to Studio",
|
||||
"badge": "Commercial License",
|
||||
"hero_title": "Ship AI voices in production",
|
||||
"hero_desc": "OmniVoice Studio is source-available under the Functional Source License (FSL). Most users can evaluate, prototype, and even deploy internally without a commercial agreement. You need a commercial license only if you are building a competing product or service, or if your use case falls outside the FSL’s boundaries.",
|
||||
"hero_note": "Internal use — even at scale — is free under the FSL; a commercial license is only required to offer OmniVoice to others as a competing product or service (a hosted or pay-per-use API, a resold or white-labelled app). Pricing tiers are coming soon — get in touch in the meantime.",
|
||||
"hero_desc": "OmniVoice Studio is free and open-source software under the GNU Affero General Public License v3 (AGPL-3.0) — free to use, including for commercial and internal business use. A commercial license is needed only if you want to embed OmniVoice Studio in a closed-source or proprietary product or service without AGPL-3.0's copyleft obligations.",
|
||||
"hero_note": "Use, self-hosting, and commercial use are all free under the AGPL-3.0 — including at scale. AGPL is a network-copyleft license: if you modify OmniVoice and offer that modified version to others over a network, you must share your modified source under the same terms. A commercial license lifts those copyleft obligations for proprietary, closed-source deployments. Pricing tiers are coming soon — get in touch in the meantime.",
|
||||
"why_title": "Why businesses choose OmniVoice",
|
||||
"pricing_title": "Pricing",
|
||||
"faq_title": "Common questions",
|
||||
@@ -997,7 +1010,8 @@
|
||||
"script_french": "Non-English (French)",
|
||||
"aria_pause": "Pause {{label}}",
|
||||
"aria_hear": "Hear {{label}}",
|
||||
"aria_replay": "Replay {{label}} through transcriber"
|
||||
"aria_replay": "Replay {{label}} through transcriber",
|
||||
"dictation_lede_hotkey_only": "Hold the shortcut above anywhere on your desktop, speak, release — the text lands in whatever app has focus. Press it now to verify it works."
|
||||
},
|
||||
"bootstrap": {
|
||||
"title": "OmniVoice Studio",
|
||||
@@ -1088,7 +1102,10 @@
|
||||
"title": "This tab hit a snag.",
|
||||
"desc": "Don't worry — the rest of the app still works. You can switch tabs, or try again below.",
|
||||
"tryAgain": "Try again",
|
||||
"openDocs": "Open docs for this error"
|
||||
"openDocs": "Open docs for this error",
|
||||
"report": "Report this bug",
|
||||
"searchIssues": "Search similar issues",
|
||||
"unexpected": "Unexpected error: {{message}}"
|
||||
},
|
||||
"common": {
|
||||
"open": "Open",
|
||||
@@ -1444,13 +1461,11 @@
|
||||
},
|
||||
"enterprise_faq": {
|
||||
"q_internal_tools": "Do I need a license for internal tools?",
|
||||
"a_internal_tools": "Internal use by your employees and contractors is a Permitted Purpose under the FSL — no license required. A commercial license is needed when you make OmniVoice available to others as part of a competing product or service (resale, hosted SaaS, white-label).",
|
||||
"a_internal_tools": "No. Use by your employees and contractors — including modifying and self-hosting internally — is free under the AGPL-3.0. A commercial license is only needed if you embed OmniVoice in a closed-source or proprietary product or service and don't want to comply with AGPL's source-sharing obligations.",
|
||||
"q_try_before": "Can I try before committing?",
|
||||
"a_try_before": "Yes. The full app is free to download and run locally for evaluation under the FSL. When you're ready to discuss a commercial deployment, email us and we'll work through the details together.",
|
||||
"a_try_before": "Yes. The full app is free to download, run, and self-host under the AGPL-3.0 — no agreement required. When you're ready to discuss a commercial (proprietary-use) license, email us and we'll work through the details together.",
|
||||
"q_watermark": "What about the watermark?",
|
||||
"a_watermark": "The invisible AudioSeal watermark is embedded by default. Commercial licensees can disable it in Settings → Privacy. Free/personal use always includes the watermark.",
|
||||
"q_apache": "Does the source ever become Apache 2.0?",
|
||||
"a_apache": "Yes. Each release converts automatically to the Apache License, Version 2.0 on the second anniversary of its publication. That means today's release is Apache 2.0 in two years, no action required from us — the FSL guarantees it irrevocably."
|
||||
"a_watermark": "The invisible AudioSeal watermark is embedded by default for everyone. Commercial licensees can disable it in Settings → Privacy."
|
||||
},
|
||||
"gallery_extra": {
|
||||
"save_prompt": "Enter a name for this voice profile:",
|
||||
@@ -1589,5 +1604,73 @@
|
||||
"other_ways": "Other ways to help",
|
||||
"star_github": "Star on GitHub",
|
||||
"join_discord": "Join Discord"
|
||||
},
|
||||
"firstrun": {
|
||||
"loading": "Preparing setup…",
|
||||
"title": "Set up OmniVoice Studio",
|
||||
"subtitle": "Nothing is installed yet — review where everything goes, then start. You can change these later in Settings.",
|
||||
"language": "Language",
|
||||
"mode_title": "Install mode",
|
||||
"mode_installed": "Installed",
|
||||
"mode_installed_desc": "Uses standard system folders. Recommended for most users.",
|
||||
"mode_portable": "Portable",
|
||||
"mode_portable_desc": "Everything lives in one folder next to the app — move it to another disk or machine as a unit.",
|
||||
"mode_portable_unavailable": "Unavailable: the folder next to the app is not writable.",
|
||||
"storage_title": "Storage",
|
||||
"portable_folder": "Portable folder",
|
||||
"portable_folder_desc": "App environment, models, and your voice data — one folder, fully movable.",
|
||||
"env_dir": "App environment",
|
||||
"env_dir_desc": "Python runtime and AI libraries.",
|
||||
"data_dir": "Voice data & projects",
|
||||
"data_dir_desc": "Your voices, dubs, outputs and project database.",
|
||||
"models_dir": "Model cache",
|
||||
"models_dir_desc": "Downloaded AI models — the largest and most relocatable part.",
|
||||
"needs": "needs ~{{size}}",
|
||||
"free": "{{size}} free",
|
||||
"checking": "checking…",
|
||||
"not_writable": "not writable",
|
||||
"change": "Change…",
|
||||
"compute_title": "Compute",
|
||||
"compute_label": "GPU / accelerator",
|
||||
"compute_auto": "Auto (NVIDIA CUDA / Apple MPS / CPU)",
|
||||
"compute_rocm": "AMD GPU (ROCm, Linux)",
|
||||
"channel_label": "Update channel",
|
||||
"channel_stable": "Stable",
|
||||
"channel_preview": "Preview (latest main)",
|
||||
"network_title": "Network",
|
||||
"region_label": "Download region",
|
||||
"mirrors_title": "Custom mirrors (advanced)",
|
||||
"mirror_pypi": "PyPI index URL",
|
||||
"mirror_hf": "Hugging Face endpoint",
|
||||
"mirror_python": "Python downloads mirror",
|
||||
"insufficient_space": "Not enough free space: this layout needs ~{{need}} on one disk, only {{free}} available. Pick a different location.",
|
||||
"blocked_not_writable": "A chosen folder is not writable — pick a different location.",
|
||||
"total_required": "Total disk needed: ~{{size}} (one-time download on first use)",
|
||||
"start": "Start installation",
|
||||
"starting": "Starting…",
|
||||
"compute_detected": "Detected",
|
||||
"compute_match": "matches this machine",
|
||||
"compute_auto_desc": "Picks the best backend on this machine at runtime — CUDA on NVIDIA, MPS on Apple Silicon, CPU otherwise.",
|
||||
"compute_rocm_desc": "Installs PyTorch ROCm wheels for AMD graphics cards on Linux. Leave on Auto if unsure.",
|
||||
"channel_stable_desc": "Tested releases only — updates arrive after community validation.",
|
||||
"channel_preview_desc": "Rolling builds from the latest main — new engines and fixes first, occasional rough edges.",
|
||||
"installing_title": "Installing",
|
||||
"activity_title": "Activity",
|
||||
"stage_setup": "Setup",
|
||||
"stage_models": "Models & engines",
|
||||
"chip_required": "required",
|
||||
"chip_optional": "optional",
|
||||
"chip_engine": "engine",
|
||||
"lib_download": "Download",
|
||||
"lib_downloading": "downloading…",
|
||||
"lib_use": "Use",
|
||||
"lib_active": "active",
|
||||
"lib_in_settings": "install later in Settings",
|
||||
"lib_show_all": "Show {{count}} optional models",
|
||||
"trust_line": "Everything runs and stays on this machine — no account, no cloud, no telemetry.",
|
||||
"resume_note": "Interrupted downloads resume automatically — closing the app is safe.",
|
||||
"eta_left": "~{{eta}} left",
|
||||
"first_sound_text": "Welcome to your studio. Every word you hear was generated on this machine, just now.",
|
||||
"first_sound_done": "That voice? Generated seconds ago, locally. Welcome in."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -978,7 +978,7 @@
|
||||
"back": "Volver al estudio",
|
||||
"badge": "Licencia Comercial",
|
||||
"hero_title": "Enviar voces de IA en producción",
|
||||
"hero_desc": "OmniVoice Studio está disponible bajo la Licencia de fuente funcional (FSL). La mayoría de los usuarios pueden evaluar, crear prototipos e incluso implementar internamente sin un acuerdo comercial. Necesita una licencia comercial sólo si está creando un producto o servicio de la competencia, o si su caso de uso queda fuera de los límites de la FSL.",
|
||||
"hero_desc": "OmniVoice Studio es software libre y de código abierto bajo la GNU Affero General Public License v3 (AGPL-3.0): gratuito para cualquier uso, incluido el uso comercial y empresarial interno. Solo necesitas una licencia comercial si quieres integrar OmniVoice Studio en un producto o servicio propietario o de código cerrado sin las obligaciones copyleft de la AGPL-3.0.",
|
||||
"why_title": "Por qué las empresas eligen OmniVoice",
|
||||
"pricing_title": "Precios",
|
||||
"faq_title": "Preguntas comunes",
|
||||
@@ -999,7 +999,7 @@
|
||||
"benefit_source_desc": "Visibilidad total de la pila. Audite, bifurque y adapte dentro de los términos de la licencia.",
|
||||
"benefit_lang": "646 idiomas",
|
||||
"benefit_lang_desc": "Transcribe, traduce y dobla en 646 idiomas con calidad de nivel humano.",
|
||||
"hero_note": "El uso interno, incluso a escala, es gratuito según la FSL; solo se requiere una licencia comercial para ofrecer OmniVoice a otros como un producto o servicio de la competencia (una API alojada o de pago por uso, una aplicación revendida o de marca blanca). Los niveles de precios estarán disponibles pronto; póngase en contacto mientras tanto."
|
||||
"hero_note": "El uso, el autoalojamiento y el uso comercial son gratuitos bajo la AGPL-3.0, incluso a gran escala. La AGPL es una licencia copyleft de red: si modificas OmniVoice y ofreces esa versión modificada a terceros a través de una red, debes compartir tu código fuente modificado bajo los mismos términos. Una licencia comercial elimina esas obligaciones copyleft para despliegues propietarios de código cerrado. Los planes de precios llegarán pronto; mientras tanto, contáctanos."
|
||||
},
|
||||
"exportModal": {
|
||||
"export": "Exportar",
|
||||
@@ -1098,7 +1098,8 @@
|
||||
"script_french": "No inglés (francés)",
|
||||
"aria_pause": "Pausa {{label}}",
|
||||
"aria_hear": "Escuchar {{label}}",
|
||||
"aria_replay": "Reproducir {{label}} a través del transcriptor"
|
||||
"aria_replay": "Reproducir {{label}} a través del transcriptor",
|
||||
"dictation_lede_hotkey_only": "Mantén pulsado el atajo de arriba en cualquier lugar del escritorio, habla y suéltalo: el texto aparecerá en la app con foco. Púlsalo ahora para verificarlo."
|
||||
},
|
||||
"direction": {
|
||||
"title": "Dirección del segmento #{{id}}",
|
||||
@@ -1412,13 +1413,11 @@
|
||||
},
|
||||
"enterprise_faq": {
|
||||
"q_internal_tools": "¿Necesito una licencia para herramientas internas?",
|
||||
"a_internal_tools": "El uso interno por parte de sus empleados y contratistas es un Propósito permitido según la FSL; no se requiere licencia. Se necesita una licencia comercial cuando pone OmniVoice a disposición de otros como parte de un producto o servicio de la competencia (reventa, SaaS alojado, marca blanca).",
|
||||
"a_internal_tools": "No. El uso por parte de tus empleados y contratistas —incluida la modificación y el autoalojamiento interno— es gratuito bajo la AGPL-3.0. Solo necesitas una licencia comercial si integras OmniVoice en un producto o servicio propietario o de código cerrado y no quieres cumplir las obligaciones de compartir el código fuente de la AGPL.",
|
||||
"q_try_before": "¿Puedo intentarlo antes de comprometerme?",
|
||||
"a_try_before": "Sí. La aplicación completa se puede descargar y ejecutar localmente de forma gratuita para su evaluación según la FSL. Cuando esté listo para discutir una implementación comercial, envíenos un correo electrónico y trabajaremos juntos en los detalles.",
|
||||
"a_try_before": "Sí. La aplicación completa se puede descargar, ejecutar y autoalojar gratis bajo la AGPL-3.0, sin ningún acuerdo. Cuando quieras hablar de una licencia comercial (uso propietario), escríbenos y resolveremos los detalles juntos.",
|
||||
"q_watermark": "¿Qué pasa con la marca de agua?",
|
||||
"a_watermark": "La marca de agua invisible AudioSeal está incrustada de forma predeterminada. Los licenciatarios comerciales pueden desactivarlo en Configuración → Privacidad. El uso gratuito/personal siempre incluye la marca de agua.",
|
||||
"q_apache": "¿La fuente alguna vez se convierte en Apache 2.0?",
|
||||
"a_apache": "Sí. Cada versión se convierte automáticamente a la Licencia Apache, Versión 2.0 en el segundo aniversario de su publicación. Eso significa que el lanzamiento de hoy es Apache 2.0 dentro de dos años, no es necesario que hagamos nada: la FSL lo garantiza irrevocablemente."
|
||||
"a_watermark": "La marca de agua invisible AudioSeal está incrustada de forma predeterminada para todos. Los licenciatarios comerciales pueden desactivarla en Configuración → Privacidad."
|
||||
},
|
||||
"gallery_extra": {
|
||||
"save_prompt": "Ingrese un nombre para este perfil de voz:",
|
||||
@@ -1598,5 +1597,73 @@
|
||||
"none": "No se encontraron lanzamientos",
|
||||
"load_error": "No se pudieron cargar las versiones (¿sin conexión?)",
|
||||
"retry_load": "Reintentar"
|
||||
},
|
||||
"firstrun": {
|
||||
"loading": "Preparando la configuración…",
|
||||
"title": "Configurar OmniVoice Studio",
|
||||
"subtitle": "Aún no se ha instalado nada: revisa dónde irá cada cosa y luego comienza. Podrás cambiarlo después en Ajustes.",
|
||||
"language": "Idioma",
|
||||
"mode_title": "Modo de instalación",
|
||||
"mode_installed": "Instalado",
|
||||
"mode_installed_desc": "Usa las carpetas estándar del sistema. Recomendado para la mayoría.",
|
||||
"mode_portable": "Portátil",
|
||||
"mode_portable_desc": "Todo vive en una carpeta junto a la aplicación: muévela a otro disco o equipo como una unidad.",
|
||||
"mode_portable_unavailable": "No disponible: la carpeta junto a la aplicación no es escribible.",
|
||||
"storage_title": "Almacenamiento",
|
||||
"portable_folder": "Carpeta portátil",
|
||||
"portable_folder_desc": "Entorno, modelos y tus datos de voz: una sola carpeta, totalmente movible.",
|
||||
"env_dir": "Entorno de la aplicación",
|
||||
"env_dir_desc": "Runtime de Python y bibliotecas de IA.",
|
||||
"data_dir": "Datos de voz y proyectos",
|
||||
"data_dir_desc": "Tus voces, doblajes, salidas y la base de datos de proyectos.",
|
||||
"models_dir": "Caché de modelos",
|
||||
"models_dir_desc": "Modelos de IA descargados: la parte más grande y fácil de reubicar.",
|
||||
"needs": "necesita ~{{size}}",
|
||||
"free": "{{size}} libres",
|
||||
"checking": "comprobando…",
|
||||
"not_writable": "no escribible",
|
||||
"change": "Cambiar…",
|
||||
"compute_title": "Cómputo",
|
||||
"compute_label": "GPU / acelerador",
|
||||
"compute_auto": "Auto (NVIDIA CUDA / Apple MPS / CPU)",
|
||||
"compute_rocm": "GPU AMD (ROCm, Linux)",
|
||||
"channel_label": "Canal de actualizaciones",
|
||||
"channel_stable": "Estable",
|
||||
"channel_preview": "Preview (último main)",
|
||||
"network_title": "Red",
|
||||
"region_label": "Región de descarga",
|
||||
"mirrors_title": "Mirrors personalizados (avanzado)",
|
||||
"mirror_pypi": "URL del índice PyPI",
|
||||
"mirror_hf": "Endpoint de Hugging Face",
|
||||
"mirror_python": "Mirror de descargas de Python",
|
||||
"insufficient_space": "Espacio insuficiente: esta configuración necesita ~{{need}} en un mismo disco y solo hay {{free}} disponibles. Elige otra ubicación.",
|
||||
"blocked_not_writable": "Una carpeta elegida no es escribible: elige otra ubicación.",
|
||||
"total_required": "Espacio total necesario: ~{{size}} (descarga única en el primer uso)",
|
||||
"start": "Iniciar instalación",
|
||||
"starting": "Iniciando…",
|
||||
"compute_detected": "Detectado",
|
||||
"compute_match": "coincide con este equipo",
|
||||
"compute_auto_desc": "Elige el mejor backend de este equipo en tiempo de ejecución: CUDA en NVIDIA, MPS en Apple Silicon, CPU en otro caso.",
|
||||
"compute_rocm_desc": "Instala las wheels ROCm de PyTorch para tarjetas AMD en Linux. Deja Auto si tienes dudas.",
|
||||
"channel_stable_desc": "Solo versiones probadas: las actualizaciones llegan tras la validación de la comunidad.",
|
||||
"channel_preview_desc": "Builds continuas del último main: nuevos motores y arreglos antes, con algún borde áspero ocasional.",
|
||||
"installing_title": "Instalando",
|
||||
"activity_title": "Actividad",
|
||||
"stage_setup": "Configuración",
|
||||
"stage_models": "Modelos y motores",
|
||||
"chip_required": "requerido",
|
||||
"chip_optional": "opcional",
|
||||
"chip_engine": "motor",
|
||||
"lib_download": "Descargar",
|
||||
"lib_downloading": "descargando…",
|
||||
"lib_use": "Usar",
|
||||
"lib_active": "activo",
|
||||
"lib_in_settings": "instalar luego en Ajustes",
|
||||
"lib_show_all": "Mostrar {{count}} modelos opcionales",
|
||||
"trust_line": "Todo se ejecuta y permanece en esta máquina: sin cuenta, sin nube, sin telemetría.",
|
||||
"resume_note": "Las descargas interrumpidas se reanudan solas; cerrar la app es seguro.",
|
||||
"eta_left": "quedan ~{{eta}}",
|
||||
"first_sound_text": "Bienvenido a tu estudio. Cada palabra que oyes se generó en esta máquina, ahora mismo.",
|
||||
"first_sound_done": "¿Esa voz? Generada hace segundos, en local. Bienvenido."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -978,7 +978,7 @@
|
||||
"back": "Retour à l'Atelier",
|
||||
"badge": "Licence commerciale",
|
||||
"hero_title": "Expédier les voix de l'IA en production",
|
||||
"hero_desc": "OmniVoice Studio est disponible sous la licence Functional Source (FSL). La plupart des utilisateurs peuvent évaluer, prototyper et même déployer en interne sans accord commercial. Vous n’avez besoin d’une licence commerciale que si vous créez un produit ou un service concurrent, ou si votre cas d’utilisation dépasse les limites du FSL.",
|
||||
"hero_desc": "OmniVoice Studio est un logiciel libre et open source sous licence GNU Affero General Public License v3 (AGPL-3.0) — gratuit pour tout usage, y compris commercial et interne en entreprise. Une licence commerciale n'est nécessaire que si vous souhaitez intégrer OmniVoice Studio dans un produit ou service propriétaire ou à code fermé, sans les obligations copyleft de l'AGPL-3.0.",
|
||||
"why_title": "Pourquoi les entreprises choisissent OmniVoice",
|
||||
"pricing_title": "Tarifs",
|
||||
"faq_title": "Questions courantes",
|
||||
@@ -999,7 +999,7 @@
|
||||
"benefit_source_desc": "Visibilité totale sur la pile. Auditez, forkez et adaptez selon les termes de la licence.",
|
||||
"benefit_lang": "646 langues",
|
||||
"benefit_lang_desc": "Transcrivez, traduisez et doublez dans 646 langues avec une qualité humaine.",
|
||||
"hero_note": "L’utilisation interne – même à grande échelle – est gratuite dans le cadre du FSL ; une licence commerciale n'est requise que pour proposer OmniVoice à des tiers en tant que produit ou service concurrent (une API hébergée ou payante à l'utilisation, une application revendue ou en marque blanche). Les niveaux de tarification arriveront bientôt – contactez-nous en attendant."
|
||||
"hero_note": "L'utilisation, l'auto-hébergement et l'usage commercial sont gratuits sous AGPL-3.0 — y compris à grande échelle. L'AGPL est une licence copyleft réseau : si vous modifiez OmniVoice et proposez cette version modifiée à des tiers via un réseau, vous devez partager votre code source modifié aux mêmes conditions. Une licence commerciale lève ces obligations copyleft pour les déploiements propriétaires à code fermé. Les tarifs arrivent bientôt — contactez-nous d'ici là."
|
||||
},
|
||||
"exportModal": {
|
||||
"export": "Exporter",
|
||||
@@ -1098,7 +1098,8 @@
|
||||
"script_french": "Non anglais (français)",
|
||||
"aria_pause": "Pause {{label}}",
|
||||
"aria_hear": "Écoutez {{label}}",
|
||||
"aria_replay": "Rejouer {{label}} via le transcripteur"
|
||||
"aria_replay": "Rejouer {{label}} via le transcripteur",
|
||||
"dictation_lede_hotkey_only": "Maintenez le raccourci ci-dessus n'importe où sur votre bureau, parlez, relâchez — le texte arrive dans l'application active. Appuyez maintenant pour vérifier."
|
||||
},
|
||||
"direction": {
|
||||
"title": "Direction pour le segment #{{id}}",
|
||||
@@ -1412,13 +1413,11 @@
|
||||
},
|
||||
"enterprise_faq": {
|
||||
"q_internal_tools": "Ai-je besoin d’une licence pour les outils internes ?",
|
||||
"a_internal_tools": "L'utilisation interne par vos employés et sous-traitants est une finalité autorisée en vertu du FSL — aucune licence n'est requise. Une licence commerciale est nécessaire lorsque vous mettez OmniVoice à la disposition de tiers dans le cadre d'un produit ou d'un service concurrent (revente, SaaS hébergé, marque blanche).",
|
||||
"a_internal_tools": "Non. L'utilisation par vos employés et prestataires — y compris la modification et l'auto-hébergement en interne — est gratuite sous AGPL-3.0. Une licence commerciale n'est nécessaire que si vous intégrez OmniVoice dans un produit ou service propriétaire ou à code fermé sans vouloir respecter les obligations de partage du code source de l'AGPL.",
|
||||
"q_try_before": "Puis-je essayer avant de m'engager ?",
|
||||
"a_try_before": "Oui. L'application complète peut être téléchargée et exécutée gratuitement localement pour être évaluée dans le cadre du FSL. Lorsque vous êtes prêt à discuter d'un déploiement commercial, envoyez-nous un e-mail et nous examinerons les détails ensemble.",
|
||||
"a_try_before": "Oui. L'application complète est gratuite à télécharger, exécuter et auto-héberger sous AGPL-3.0 — aucun accord requis. Quand vous serez prêt à discuter d'une licence commerciale (usage propriétaire), écrivez-nous et nous verrons les détails ensemble.",
|
||||
"q_watermark": "Et le filigrane ?",
|
||||
"a_watermark": "Le filigrane invisible AudioSeal est intégré par défaut. Les titulaires de licence commerciale peuvent le désactiver dans Paramètres → Confidentialité. L’utilisation gratuite/personnelle inclut toujours le filigrane.",
|
||||
"q_apache": "La source devient-elle un jour Apache 2.0 ?",
|
||||
"a_apache": "Oui. Chaque version est automatiquement convertie en licence Apache, version 2.0 au deuxième anniversaire de sa publication. Cela signifie que la version d'aujourd'hui sera Apache 2.0 dans deux ans, aucune action n'est requise de notre part — le FSL le garantit irrévocablement."
|
||||
"a_watermark": "Le filigrane invisible AudioSeal est intégré par défaut pour tout le monde. Les titulaires de licence commerciale peuvent le désactiver dans Paramètres → Confidentialité."
|
||||
},
|
||||
"gallery_extra": {
|
||||
"save_prompt": "Saisissez un nom pour ce profil vocal :",
|
||||
@@ -1598,5 +1597,73 @@
|
||||
"none": "Aucune version trouvée",
|
||||
"load_error": "Impossible de charger les versions (hors ligne ?)",
|
||||
"retry_load": "Réessayer"
|
||||
},
|
||||
"firstrun": {
|
||||
"loading": "Préparation de la configuration…",
|
||||
"title": "Configurer OmniVoice Studio",
|
||||
"subtitle": "Rien n'est encore installé : vérifiez où tout sera placé, puis lancez. Modifiable plus tard dans les Réglages.",
|
||||
"language": "Langue",
|
||||
"mode_title": "Mode d'installation",
|
||||
"mode_installed": "Installé",
|
||||
"mode_installed_desc": "Utilise les dossiers système standard. Recommandé pour la plupart des utilisateurs.",
|
||||
"mode_portable": "Portable",
|
||||
"mode_portable_desc": "Tout vit dans un dossier à côté de l'application — déplacez-le vers un autre disque ou une autre machine d'un bloc.",
|
||||
"mode_portable_unavailable": "Indisponible : le dossier à côté de l'application n'est pas accessible en écriture.",
|
||||
"storage_title": "Stockage",
|
||||
"portable_folder": "Dossier portable",
|
||||
"portable_folder_desc": "Environnement, modèles et vos données vocales — un seul dossier, entièrement déplaçable.",
|
||||
"env_dir": "Environnement de l'application",
|
||||
"env_dir_desc": "Runtime Python et bibliothèques d'IA.",
|
||||
"data_dir": "Données vocales et projets",
|
||||
"data_dir_desc": "Vos voix, doublages, sorties et la base de données des projets.",
|
||||
"models_dir": "Cache des modèles",
|
||||
"models_dir_desc": "Modèles d'IA téléchargés — la partie la plus volumineuse et la plus facile à déplacer.",
|
||||
"needs": "requiert ~{{size}}",
|
||||
"free": "{{size}} libres",
|
||||
"checking": "vérification…",
|
||||
"not_writable": "non inscriptible",
|
||||
"change": "Modifier…",
|
||||
"compute_title": "Calcul",
|
||||
"compute_label": "GPU / accélérateur",
|
||||
"compute_auto": "Auto (NVIDIA CUDA / Apple MPS / CPU)",
|
||||
"compute_rocm": "GPU AMD (ROCm, Linux)",
|
||||
"channel_label": "Canal de mise à jour",
|
||||
"channel_stable": "Stable",
|
||||
"channel_preview": "Préversion (dernier main)",
|
||||
"network_title": "Réseau",
|
||||
"region_label": "Région de téléchargement",
|
||||
"mirrors_title": "Miroirs personnalisés (avancé)",
|
||||
"mirror_pypi": "URL de l'index PyPI",
|
||||
"mirror_hf": "Endpoint Hugging Face",
|
||||
"mirror_python": "Miroir de téléchargement Python",
|
||||
"insufficient_space": "Espace insuffisant : cette configuration nécessite ~{{need}} sur un même disque, seulement {{free}} disponibles. Choisissez un autre emplacement.",
|
||||
"blocked_not_writable": "Un dossier choisi n'est pas inscriptible — choisissez un autre emplacement.",
|
||||
"total_required": "Espace disque total : ~{{size}} (téléchargement unique au premier lancement)",
|
||||
"start": "Démarrer l'installation",
|
||||
"starting": "Démarrage…",
|
||||
"compute_detected": "Détecté",
|
||||
"compute_match": "correspond à cette machine",
|
||||
"compute_auto_desc": "Choisit le meilleur backend de cette machine à l'exécution — CUDA sur NVIDIA, MPS sur Apple Silicon, sinon CPU.",
|
||||
"compute_rocm_desc": "Installe les wheels ROCm de PyTorch pour les cartes AMD sous Linux. Laissez Auto en cas de doute.",
|
||||
"channel_stable_desc": "Uniquement des versions testées — les mises à jour arrivent après validation par la communauté.",
|
||||
"channel_preview_desc": "Builds continues du dernier main — nouveaux moteurs et correctifs en premier, quelques aspérités possibles.",
|
||||
"installing_title": "Installation",
|
||||
"activity_title": "Activité",
|
||||
"stage_setup": "Configuration",
|
||||
"stage_models": "Modèles et moteurs",
|
||||
"chip_required": "requis",
|
||||
"chip_optional": "optionnel",
|
||||
"chip_engine": "moteur",
|
||||
"lib_download": "Télécharger",
|
||||
"lib_downloading": "téléchargement…",
|
||||
"lib_use": "Utiliser",
|
||||
"lib_active": "actif",
|
||||
"lib_in_settings": "installer plus tard dans Réglages",
|
||||
"lib_show_all": "Afficher {{count}} modèles optionnels",
|
||||
"trust_line": "Tout s’exécute et reste sur cette machine — sans compte, sans cloud, sans télémétrie.",
|
||||
"resume_note": "Les téléchargements interrompus reprennent automatiquement — fermer l’application est sans risque.",
|
||||
"eta_left": "~{{eta}} restantes",
|
||||
"first_sound_text": "Bienvenue dans votre studio. Chaque mot que vous entendez vient d’être généré sur cette machine.",
|
||||
"first_sound_done": "Cette voix ? Générée il y a quelques secondes, en local. Bienvenue."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -977,7 +977,7 @@
|
||||
"back": "स्टूडियो में वापस",
|
||||
"badge": "वाणिज्यिक लाइसेंस",
|
||||
"hero_title": "उत्पादन में एआई आवाजें भेजें",
|
||||
"hero_desc": "ओमनीवॉइस स्टूडियो फंक्शनल सोर्स लाइसेंस (एफएसएल) के तहत स्रोत-उपलब्ध है। अधिकांश उपयोगकर्ता व्यावसायिक समझौते के बिना मूल्यांकन, प्रोटोटाइप और यहां तक कि आंतरिक रूप से तैनात भी कर सकते हैं। आपको वाणिज्यिक लाइसेंस की आवश्यकता केवल तभी होती है जब आप कोई प्रतिस्पर्धी उत्पाद या सेवा बना रहे हों, या यदि आपका उपयोग मामला एफएसएल की सीमाओं से बाहर हो।",
|
||||
"hero_desc": "OmniVoice Studio GNU Affero General Public License v3 (AGPL-3.0) के अंतर्गत मुफ़्त और ओपन-सोर्स सॉफ़्टवेयर है — व्यावसायिक और आंतरिक कारोबारी उपयोग सहित, उपयोग के लिए मुफ़्त। व्यावसायिक लाइसेंस केवल तभी चाहिए जब आप AGPL-3.0 की कॉपीलेफ़्ट बाध्यताओं के बिना OmniVoice Studio को किसी क्लोज़्ड-सोर्स या प्रोप्राइटरी उत्पाद या सेवा में शामिल करना चाहें।",
|
||||
"why_title": "व्यवसाय ओमनीवॉइस क्यों चुनते हैं?",
|
||||
"pricing_title": "मूल्य निर्धारण",
|
||||
"faq_title": "सामान्य प्रश्न",
|
||||
@@ -998,7 +998,7 @@
|
||||
"benefit_source_desc": "स्टैक में पूर्ण दृश्यता. लाइसेंस शर्तों के भीतर ऑडिट, फोर्क और अनुकूलन करें।",
|
||||
"benefit_lang": "646 भाषाएँ",
|
||||
"benefit_lang_desc": "मानव-स्तरीय गुणवत्ता के साथ 646 भाषाओं में प्रतिलेखन, अनुवाद और डब करें।",
|
||||
"hero_note": "आंतरिक उपयोग - यहां तक कि बड़े पैमाने पर - एफएसएल के तहत मुफ़्त है; एक वाणिज्यिक लाइसेंस की आवश्यकता केवल दूसरों को एक प्रतिस्पर्धी उत्पाद या सेवा (एक होस्टेड या भुगतान-प्रति-उपयोग एपीआई, एक पुनर्विक्रय या व्हाइट-लेबल ऐप) के रूप में ओमनीवॉइस की पेशकश करने के लिए आवश्यक है। मूल्य निर्धारण स्तर जल्द ही आ रहे हैं - इस बीच संपर्क करें।"
|
||||
"hero_note": "उपयोग, सेल्फ़-होस्टिंग और व्यावसायिक उपयोग — बड़े पैमाने पर भी — AGPL-3.0 के अंतर्गत सभी मुफ़्त हैं। AGPL एक नेटवर्क-कॉपीलेफ़्ट लाइसेंस है: यदि आप OmniVoice को संशोधित करके वह संशोधित संस्करण नेटवर्क के ज़रिये दूसरों को उपलब्ध कराते हैं, तो आपको अपना संशोधित सोर्स कोड उन्हीं शर्तों पर साझा करना होगा। व्यावसायिक लाइसेंस प्रोप्राइटरी, क्लोज़्ड-सोर्स परिनियोजनों के लिए ये कॉपीलेफ़्ट बाध्यताएँ हटा देता है। मूल्य-योजनाएँ जल्द आ रही हैं — तब तक हमसे संपर्क करें।"
|
||||
},
|
||||
"exportModal": {
|
||||
"export": "निर्यात करें",
|
||||
@@ -1097,7 +1097,8 @@
|
||||
"script_french": "गैर-अंग्रेज़ी (फ़्रेंच)",
|
||||
"aria_pause": "रोकें{{label}}",
|
||||
"aria_hear": "सुनें {{label}}",
|
||||
"aria_replay": "प्रतिलेखक के माध्यम से {{label}} को पुनः चलाएँ"
|
||||
"aria_replay": "प्रतिलेखक के माध्यम से {{label}} को पुनः चलाएँ",
|
||||
"dictation_lede_hotkey_only": "डेस्कटॉप पर कहीं भी ऊपर वाला शॉर्टकट दबाए रखें, बोलें, छोड़ें — टेक्स्ट फ़ोकस वाले ऐप में आ जाएगा। अभी दबाकर जाँचें।"
|
||||
},
|
||||
"direction": {
|
||||
"title": "खंड #{{id}} के लिए दिशा",
|
||||
@@ -1411,13 +1412,11 @@
|
||||
},
|
||||
"enterprise_faq": {
|
||||
"q_internal_tools": "क्या मुझे आंतरिक उपकरणों के लिए लाइसेंस की आवश्यकता है?",
|
||||
"a_internal_tools": "आपके कर्मचारियों और ठेकेदारों द्वारा आंतरिक उपयोग एफएसएल के तहत एक अनुमत उद्देश्य है - किसी लाइसेंस की आवश्यकता नहीं है। जब आप किसी प्रतिस्पर्धी उत्पाद या सेवा (पुनर्विक्रय, होस्ट किए गए SaaS, व्हाइट-लेबल) के हिस्से के रूप में ओमनीवॉइस को दूसरों के लिए उपलब्ध कराते हैं तो एक वाणिज्यिक लाइसेंस की आवश्यकता होती है।",
|
||||
"a_internal_tools": "नहीं। आपके कर्मचारियों और ठेकेदारों द्वारा उपयोग — आंतरिक संशोधन और सेल्फ़-होस्टिंग सहित — AGPL-3.0 के अंतर्गत मुफ़्त है। व्यावसायिक लाइसेंस केवल तभी चाहिए जब आप OmniVoice को किसी क्लोज़्ड-सोर्स या प्रोप्राइटरी उत्पाद या सेवा में शामिल करें और AGPL की सोर्स साझा करने की बाध्यताओं का पालन न करना चाहें।",
|
||||
"q_try_before": "क्या मैं प्रतिबद्ध होने से पहले प्रयास कर सकता हूँ?",
|
||||
"a_try_before": "हाँ. पूरा ऐप एफएसएल के तहत मूल्यांकन के लिए स्थानीय स्तर पर डाउनलोड करने और चलाने के लिए मुफ़्त है। जब आप किसी व्यावसायिक तैनाती पर चर्चा करने के लिए तैयार हों, तो हमें ईमेल करें और हम विवरण पर मिलकर काम करेंगे।",
|
||||
"a_try_before": "हाँ। पूरा ऐप AGPL-3.0 के अंतर्गत मुफ़्त डाउनलोड, चलाने और सेल्फ़-होस्ट करने के लिए उपलब्ध है — किसी समझौते की ज़रूरत नहीं। जब आप व्यावसायिक (प्रोप्राइटरी-उपयोग) लाइसेंस पर चर्चा के लिए तैयार हों, तो हमें ईमेल करें और हम मिलकर विवरण तय करेंगे।",
|
||||
"q_watermark": "वॉटरमार्क के बारे में क्या?",
|
||||
"a_watermark": "अदृश्य ऑडियोसील वॉटरमार्क डिफ़ॉल्ट रूप से एम्बेडेड है। वाणिज्यिक लाइसेंसधारी इसे सेटिंग्स → गोपनीयता में अक्षम कर सकते हैं। निःशुल्क/व्यक्तिगत उपयोग में हमेशा वॉटरमार्क शामिल होता है।",
|
||||
"q_apache": "क्या स्रोत कभी अपाचे 2.0 बन जाता है?",
|
||||
"a_apache": "हाँ. प्रत्येक रिलीज़ अपने प्रकाशन की दूसरी वर्षगांठ पर स्वचालित रूप से अपाचे लाइसेंस, संस्करण 2.0 में परिवर्तित हो जाती है। इसका मतलब है कि आज की रिलीज़ दो वर्षों में अपाचे 2.0 है, हमारी ओर से किसी कार्रवाई की आवश्यकता नहीं है - एफएसएल इसकी अपरिवर्तनीय गारंटी देता है।"
|
||||
"a_watermark": "अदृश्य AudioSeal वॉटरमार्क डिफ़ॉल्ट रूप से सभी के लिए एम्बेडेड है। व्यावसायिक लाइसेंसधारी इसे सेटिंग्स → गोपनीयता में अक्षम कर सकते हैं।"
|
||||
},
|
||||
"gallery_extra": {
|
||||
"save_prompt": "इस ध्वनि प्रोफ़ाइल के लिए एक नाम दर्ज करें:",
|
||||
@@ -1597,5 +1596,73 @@
|
||||
"none": "कोई रिलीज़ नहीं मिली",
|
||||
"load_error": "रिलीज़ लोड नहीं हो सकी (ऑफ़लाइन?)",
|
||||
"retry_load": "पुनः प्रयास करें"
|
||||
},
|
||||
"firstrun": {
|
||||
"loading": "सेटअप तैयार हो रहा है…",
|
||||
"title": "OmniVoice Studio सेट करें",
|
||||
"subtitle": "अभी कुछ भी इंस्टॉल नहीं हुआ है — देखें कि सब कहाँ जाएगा, फिर शुरू करें। बाद में सेटिंग्स में बदल सकते हैं।",
|
||||
"language": "भाषा",
|
||||
"mode_title": "इंस्टॉल मोड",
|
||||
"mode_installed": "इंस्टॉल्ड",
|
||||
"mode_installed_desc": "सिस्टम के मानक फ़ोल्डर इस्तेमाल करता है। अधिकांश उपयोगकर्ताओं के लिए अनुशंसित।",
|
||||
"mode_portable": "पोर्टेबल",
|
||||
"mode_portable_desc": "सब कुछ ऐप के बगल में एक फ़ोल्डर में रहता है — इसे किसी और डिस्क या मशीन पर एक साथ ले जाएँ।",
|
||||
"mode_portable_unavailable": "अनुपलब्ध: ऐप के बगल वाला फ़ोल्डर लिखने योग्य नहीं है।",
|
||||
"storage_title": "स्टोरेज",
|
||||
"portable_folder": "पोर्टेबल फ़ोल्डर",
|
||||
"portable_folder_desc": "ऐप एनवायरनमेंट, मॉडल और आपका वॉइस डेटा — एक फ़ोल्डर, पूरी तरह मूवेबल।",
|
||||
"env_dir": "ऐप एनवायरनमेंट",
|
||||
"env_dir_desc": "Python रनटाइम और AI लाइब्रेरीज़।",
|
||||
"data_dir": "वॉइस डेटा और प्रोजेक्ट",
|
||||
"data_dir_desc": "आपकी आवाज़ें, डबिंग, आउटपुट और प्रोजेक्ट डेटाबेस।",
|
||||
"models_dir": "मॉडल कैश",
|
||||
"models_dir_desc": "डाउनलोड किए गए AI मॉडल — सबसे बड़ा और आसानी से स्थानांतरित होने वाला हिस्सा।",
|
||||
"needs": "~{{size}} चाहिए",
|
||||
"free": "{{size}} खाली",
|
||||
"checking": "जाँच रहा है…",
|
||||
"not_writable": "लिखने योग्य नहीं",
|
||||
"change": "बदलें…",
|
||||
"compute_title": "कंप्यूट",
|
||||
"compute_label": "GPU / एक्सेलेरेटर",
|
||||
"compute_auto": "ऑटो (NVIDIA CUDA / Apple MPS / CPU)",
|
||||
"compute_rocm": "AMD GPU (ROCm, Linux)",
|
||||
"channel_label": "अपडेट चैनल",
|
||||
"channel_stable": "स्थिर",
|
||||
"channel_preview": "प्रीव्यू (नवीनतम main)",
|
||||
"network_title": "नेटवर्क",
|
||||
"region_label": "डाउनलोड क्षेत्र",
|
||||
"mirrors_title": "कस्टम मिरर (उन्नत)",
|
||||
"mirror_pypi": "PyPI इंडेक्स URL",
|
||||
"mirror_hf": "Hugging Face एंडपॉइंट",
|
||||
"mirror_python": "Python डाउनलोड मिरर",
|
||||
"insufficient_space": "जगह कम है: इस लेआउट को एक ही डिस्क पर ~{{need}} चाहिए, केवल {{free}} उपलब्ध है। दूसरी जगह चुनें।",
|
||||
"blocked_not_writable": "चुना गया फ़ोल्डर लिखने योग्य नहीं है — दूसरी जगह चुनें।",
|
||||
"total_required": "कुल आवश्यक डिस्क: ~{{size}} (पहली बार में एक-बार डाउनलोड)",
|
||||
"start": "इंस्टॉलेशन शुरू करें",
|
||||
"starting": "शुरू हो रहा है…",
|
||||
"compute_detected": "पहचाना गया",
|
||||
"compute_match": "इस मशीन से मेल खाता है",
|
||||
"compute_auto_desc": "रनटाइम पर इस मशीन का सर्वश्रेष्ठ बैकएंड चुनता है — NVIDIA पर CUDA, Apple Silicon पर MPS, अन्यथा CPU।",
|
||||
"compute_rocm_desc": "Linux पर AMD ग्राफ़िक्स कार्ड के लिए PyTorch ROCm व्हील इंस्टॉल करता है। संदेह हो तो Auto पर रहने दें।",
|
||||
"channel_stable_desc": "केवल परीक्षित रिलीज़ — सामुदायिक सत्यापन के बाद अपडेट आते हैं।",
|
||||
"channel_preview_desc": "नवीनतम main की रोलिंग बिल्ड — नए इंजन और फ़िक्स सबसे पहले, कभी-कभी छोटी खामियाँ।",
|
||||
"installing_title": "इंस्टॉल हो रहा है",
|
||||
"activity_title": "गतिविधि",
|
||||
"stage_setup": "सेटअप",
|
||||
"stage_models": "मॉडल और इंजन",
|
||||
"chip_required": "आवश्यक",
|
||||
"chip_optional": "वैकल्पिक",
|
||||
"chip_engine": "इंजन",
|
||||
"lib_download": "डाउनलोड",
|
||||
"lib_downloading": "डाउनलोड हो रहा है…",
|
||||
"lib_use": "उपयोग करें",
|
||||
"lib_active": "सक्रिय",
|
||||
"lib_in_settings": "बाद में सेटिंग्स में इंस्टॉल करें",
|
||||
"lib_show_all": "{{count}} वैकल्पिक मॉडल दिखाएँ",
|
||||
"trust_line": "सब कुछ इसी मशीन पर चलता और रहता है — न खाता, न क्लाउड, न टेलीमेट्री।",
|
||||
"resume_note": "रुके हुए डाउनलोड अपने आप फिर शुरू हो जाते हैं — ऐप बंद करना सुरक्षित है।",
|
||||
"eta_left": "~{{eta}} शेष",
|
||||
"first_sound_text": "आपके स्टूडियो में स्वागत है। आप जो भी शब्द सुन रहे हैं, वह अभी-अभी इसी मशीन पर बना है।",
|
||||
"first_sound_done": "वह आवाज़? कुछ सेकंड पहले, लोकली बनी। स्वागत है।"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -977,7 +977,7 @@
|
||||
"back": "Kembali ke Studio",
|
||||
"badge": "Lisensi Komersial",
|
||||
"hero_title": "Kirimkan suara AI dalam produksi",
|
||||
"hero_desc": "OmniVoice Studio tersedia sumbernya di bawah Lisensi Sumber Fungsional (FSL). Sebagian besar pengguna dapat mengevaluasi, membuat prototipe, dan bahkan menerapkan secara internal tanpa perjanjian komersial. Anda memerlukan lisensi komersial hanya jika Anda membuat produk atau layanan pesaing, atau jika kasus penggunaan Anda berada di luar batasan FSL.",
|
||||
"hero_desc": "OmniVoice Studio adalah perangkat lunak bebas dan open source di bawah GNU Affero General Public License v3 (AGPL-3.0) — gratis digunakan, termasuk untuk penggunaan komersial dan bisnis internal. Lisensi komersial hanya diperlukan jika Anda ingin menyematkan OmniVoice Studio ke dalam produk atau layanan closed-source atau proprietary tanpa kewajiban copyleft AGPL-3.0.",
|
||||
"why_title": "Mengapa bisnis memilih OmniVoice",
|
||||
"pricing_title": "Harga",
|
||||
"faq_title": "Pertanyaan umum",
|
||||
@@ -998,7 +998,7 @@
|
||||
"benefit_source_desc": "Visibilitas penuh ke dalam tumpukan. Audit, fork, dan adaptasi sesuai ketentuan lisensi.",
|
||||
"benefit_lang": "646 bahasa",
|
||||
"benefit_lang_desc": "Transkripsikan, terjemahkan, dan sulih suara dalam 646 bahasa dengan kualitas setingkat manusia.",
|
||||
"hero_note": "Penggunaan internal — bahkan dalam skala besar — gratis di bawah FSL; lisensi komersial hanya diperlukan untuk menawarkan OmniVoice kepada orang lain sebagai produk atau layanan pesaing (API yang dihosting atau bayar per penggunaan, aplikasi yang dijual kembali atau diberi label putih). Tingkatan harga akan segera hadir — hubungi kami sementara ini."
|
||||
"hero_note": "Penggunaan, self-hosting, dan penggunaan komersial semuanya gratis di bawah AGPL-3.0 — termasuk dalam skala besar. AGPL adalah lisensi copyleft jaringan: jika Anda memodifikasi OmniVoice dan menawarkan versi modifikasi itu kepada orang lain melalui jaringan, Anda harus membagikan kode sumber modifikasi Anda dengan ketentuan yang sama. Lisensi komersial menghapus kewajiban copyleft tersebut untuk penerapan proprietary closed-source. Paket harga segera hadir — sementara itu, hubungi kami."
|
||||
},
|
||||
"exportModal": {
|
||||
"export": "Ekspor",
|
||||
@@ -1097,7 +1097,8 @@
|
||||
"script_french": "Non-Inggris (Prancis)",
|
||||
"aria_pause": "Jeda {{label}}",
|
||||
"aria_hear": "Dengarkan {{label}}",
|
||||
"aria_replay": "Putar ulang {{label}} melalui transcriber"
|
||||
"aria_replay": "Putar ulang {{label}} melalui transcriber",
|
||||
"dictation_lede_hotkey_only": "Tahan pintasan di atas di mana saja di desktop, bicara, lepaskan — teks masuk ke aplikasi yang sedang fokus. Tekan sekarang untuk memverifikasi."
|
||||
},
|
||||
"direction": {
|
||||
"title": "Arah untuk segmen #{{id}}",
|
||||
@@ -1411,13 +1412,11 @@
|
||||
},
|
||||
"enterprise_faq": {
|
||||
"q_internal_tools": "Apakah saya memerlukan lisensi untuk alat internal?",
|
||||
"a_internal_tools": "Penggunaan internal oleh karyawan dan kontraktor Anda adalah Tujuan yang Diizinkan berdasarkan FSL — tidak diperlukan lisensi. Lisensi komersial diperlukan saat Anda membuat OmniVoice tersedia bagi orang lain sebagai bagian dari produk atau layanan pesaing (dijual kembali, SaaS yang dihosting, label putih).",
|
||||
"a_internal_tools": "Tidak. Penggunaan oleh karyawan dan kontraktor Anda — termasuk memodifikasi dan self-hosting secara internal — gratis di bawah AGPL-3.0. Lisensi komersial hanya diperlukan jika Anda menyematkan OmniVoice ke dalam produk atau layanan closed-source atau proprietary dan tidak ingin mematuhi kewajiban berbagi kode sumber AGPL.",
|
||||
"q_try_before": "Bisakah saya mencoba sebelum melakukan?",
|
||||
"a_try_before": "Ya. Aplikasi lengkapnya gratis untuk diunduh dan dijalankan secara lokal untuk evaluasi di bawah FSL. Saat Anda siap mendiskusikan penerapan komersial, kirimkan email kepada kami dan kami akan membahas detailnya bersama-sama.",
|
||||
"a_try_before": "Ya. Aplikasi lengkap gratis untuk diunduh, dijalankan, dan di-host sendiri di bawah AGPL-3.0 — tanpa perjanjian apa pun. Saat Anda siap membahas lisensi komersial (penggunaan proprietary), kirim email kepada kami dan kita akan menyelesaikan detailnya bersama.",
|
||||
"q_watermark": "Bagaimana dengan tanda airnya?",
|
||||
"a_watermark": "Tanda air AudioSeal yang tidak terlihat tertanam secara default. Pemegang lisensi komersial dapat menonaktifkannya di Pengaturan → Privasi. Penggunaan gratis/pribadi selalu menyertakan tanda air.",
|
||||
"q_apache": "Apakah sumbernya pernah menjadi Apache 2.0?",
|
||||
"a_apache": "Ya. Setiap rilis dikonversi secara otomatis ke Lisensi Apache, Versi 2.0 pada ulang tahun kedua penerbitannya. Itu berarti rilis hari ini adalah Apache 2.0 dalam dua tahun, kami tidak perlu mengambil tindakan apa pun — FSL menjaminnya tanpa dapat ditarik kembali."
|
||||
"a_watermark": "Tanda air AudioSeal yang tidak terlihat tertanam secara default untuk semua orang. Pemegang lisensi komersial dapat menonaktifkannya di Pengaturan → Privasi."
|
||||
},
|
||||
"gallery_extra": {
|
||||
"save_prompt": "Masukkan nama untuk profil suara ini:",
|
||||
@@ -1597,5 +1596,73 @@
|
||||
"none": "Tidak ada rilis yang ditemukan",
|
||||
"load_error": "Tidak dapat memuat rilis (offline?)",
|
||||
"retry_load": "Coba lagi"
|
||||
},
|
||||
"firstrun": {
|
||||
"loading": "Menyiapkan penyiapan…",
|
||||
"title": "Siapkan OmniVoice Studio",
|
||||
"subtitle": "Belum ada yang terpasang — tinjau ke mana semuanya disimpan, lalu mulai. Bisa diubah nanti di Pengaturan.",
|
||||
"language": "Bahasa",
|
||||
"mode_title": "Mode pemasangan",
|
||||
"mode_installed": "Terpasang",
|
||||
"mode_installed_desc": "Menggunakan folder sistem standar. Disarankan untuk sebagian besar pengguna.",
|
||||
"mode_portable": "Portabel",
|
||||
"mode_portable_desc": "Semuanya berada dalam satu folder di samping aplikasi — pindahkan utuh ke disk atau mesin lain.",
|
||||
"mode_portable_unavailable": "Tidak tersedia: folder di samping aplikasi tidak dapat ditulisi.",
|
||||
"storage_title": "Penyimpanan",
|
||||
"portable_folder": "Folder portabel",
|
||||
"portable_folder_desc": "Lingkungan, model, dan data suara Anda — satu folder, sepenuhnya dapat dipindah.",
|
||||
"env_dir": "Lingkungan aplikasi",
|
||||
"env_dir_desc": "Runtime Python dan pustaka AI.",
|
||||
"data_dir": "Data suara & proyek",
|
||||
"data_dir_desc": "Suara Anda, sulih suara, keluaran, dan basis data proyek.",
|
||||
"models_dir": "Cache model",
|
||||
"models_dir_desc": "Model AI yang diunduh — bagian terbesar dan paling mudah dipindah.",
|
||||
"needs": "butuh ~{{size}}",
|
||||
"free": "{{size}} kosong",
|
||||
"checking": "memeriksa…",
|
||||
"not_writable": "tidak dapat ditulisi",
|
||||
"change": "Ubah…",
|
||||
"compute_title": "Komputasi",
|
||||
"compute_label": "GPU / akselerator",
|
||||
"compute_auto": "Otomatis (NVIDIA CUDA / Apple MPS / CPU)",
|
||||
"compute_rocm": "GPU AMD (ROCm, Linux)",
|
||||
"channel_label": "Saluran pembaruan",
|
||||
"channel_stable": "Stabil",
|
||||
"channel_preview": "Pratinjau (main terbaru)",
|
||||
"network_title": "Jaringan",
|
||||
"region_label": "Wilayah unduhan",
|
||||
"mirrors_title": "Mirror kustom (lanjutan)",
|
||||
"mirror_pypi": "URL indeks PyPI",
|
||||
"mirror_hf": "Endpoint Hugging Face",
|
||||
"mirror_python": "Mirror unduhan Python",
|
||||
"insufficient_space": "Ruang tidak cukup: tata letak ini butuh ~{{need}} pada satu disk, hanya tersedia {{free}}. Pilih lokasi lain.",
|
||||
"blocked_not_writable": "Folder yang dipilih tidak dapat ditulisi — pilih lokasi lain.",
|
||||
"total_required": "Total disk yang dibutuhkan: ~{{size}} (unduhan sekali saat pertama dipakai)",
|
||||
"start": "Mulai pemasangan",
|
||||
"starting": "Memulai…",
|
||||
"compute_detected": "Terdeteksi",
|
||||
"compute_match": "cocok dengan mesin ini",
|
||||
"compute_auto_desc": "Memilih backend terbaik mesin ini saat berjalan — CUDA di NVIDIA, MPS di Apple Silicon, selain itu CPU.",
|
||||
"compute_rocm_desc": "Memasang wheel PyTorch ROCm untuk kartu grafis AMD di Linux. Biarkan Auto jika ragu.",
|
||||
"channel_stable_desc": "Hanya rilis teruji — pembaruan tiba setelah validasi komunitas.",
|
||||
"channel_preview_desc": "Build bergulir dari main terbaru — mesin dan perbaikan baru lebih dulu, sesekali ada sisi kasar.",
|
||||
"installing_title": "Memasang",
|
||||
"activity_title": "Aktivitas",
|
||||
"stage_setup": "Penyiapan",
|
||||
"stage_models": "Model & mesin",
|
||||
"chip_required": "wajib",
|
||||
"chip_optional": "opsional",
|
||||
"chip_engine": "mesin",
|
||||
"lib_download": "Unduh",
|
||||
"lib_downloading": "mengunduh…",
|
||||
"lib_use": "Pakai",
|
||||
"lib_active": "aktif",
|
||||
"lib_in_settings": "pasang nanti di Pengaturan",
|
||||
"lib_show_all": "Tampilkan {{count}} model opsional",
|
||||
"trust_line": "Semuanya berjalan dan tersimpan di mesin ini — tanpa akun, tanpa cloud, tanpa telemetri.",
|
||||
"resume_note": "Unduhan yang terputus dilanjutkan otomatis — menutup aplikasi aman.",
|
||||
"eta_left": "sisa ~{{eta}}",
|
||||
"first_sound_text": "Selamat datang di studio Anda. Setiap kata yang Anda dengar baru saja dihasilkan di mesin ini.",
|
||||
"first_sound_done": "Suara tadi? Dihasilkan beberapa detik lalu, secara lokal. Selamat datang."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -977,7 +977,7 @@
|
||||
"back": "Ritorno allo Studio",
|
||||
"badge": "Licenza commerciale",
|
||||
"hero_title": "Spedisci voci AI in produzione",
|
||||
"hero_desc": "OmniVoice Studio è disponibile come codice sorgente sotto la Functional Source License (FSL). La maggior parte degli utenti può valutare, prototipare e persino implementare internamente senza un accordo commerciale. Hai bisogno di una licenza commerciale solo se stai creando un prodotto o servizio concorrente o se il tuo caso d'uso non rientra nei confini dell'FSL.",
|
||||
"hero_desc": "OmniVoice Studio è software libero e open source sotto licenza GNU Affero General Public License v3 (AGPL-3.0): gratuito per qualsiasi uso, incluso quello commerciale e aziendale interno. Una licenza commerciale serve solo se vuoi integrare OmniVoice Studio in un prodotto o servizio proprietario o closed-source senza gli obblighi copyleft della AGPL-3.0.",
|
||||
"why_title": "Perché le aziende scelgono OmniVoice",
|
||||
"pricing_title": "Prezzi",
|
||||
"faq_title": "Domande comuni",
|
||||
@@ -998,7 +998,7 @@
|
||||
"benefit_source_desc": "Visibilità completa nello stack. Controlla, effettua il fork e adatta entro i termini della licenza.",
|
||||
"benefit_lang": "646 lingue",
|
||||
"benefit_lang_desc": "Trascrivi, traduci e doppia in 646 lingue con una qualità a livello umano.",
|
||||
"hero_note": "L’uso interno – anche su larga scala – è gratuito secondo l’FSL; è necessaria una licenza commerciale solo per offrire OmniVoice ad altri come prodotto o servizio concorrente (un'API ospitata o a pagamento, un'app rivenduta o con etichetta bianca). I livelli di prezzo arriveranno presto: contattaci nel frattempo."
|
||||
"hero_note": "Uso, self-hosting e uso commerciale sono tutti gratuiti sotto AGPL-3.0, anche su larga scala. La AGPL è una licenza copyleft di rete: se modifichi OmniVoice e offri quella versione modificata ad altri tramite rete, devi condividere il tuo codice sorgente modificato agli stessi termini. Una licenza commerciale rimuove questi obblighi copyleft per i deployment proprietari closed-source. I piani tariffari arriveranno presto: nel frattempo contattaci."
|
||||
},
|
||||
"exportModal": {
|
||||
"export": "Esportazione",
|
||||
@@ -1097,7 +1097,8 @@
|
||||
"script_french": "Non inglese (francese)",
|
||||
"aria_pause": "Pausa {{label}}",
|
||||
"aria_hear": "Ascolta {{label}}",
|
||||
"aria_replay": "Riproduci {{label}} tramite il trascrittore"
|
||||
"aria_replay": "Riproduci {{label}} tramite il trascrittore",
|
||||
"dictation_lede_hotkey_only": "Tieni premuta la scorciatoia qui sopra ovunque sul desktop, parla e rilascia — il testo arriva nell'app attiva. Premila ora per verificarla."
|
||||
},
|
||||
"direction": {
|
||||
"title": "Direzione per il segmento #{{id}}",
|
||||
@@ -1411,13 +1412,11 @@
|
||||
},
|
||||
"enterprise_faq": {
|
||||
"q_internal_tools": "Ho bisogno di una licenza per gli strumenti interni?",
|
||||
"a_internal_tools": "L'uso interno da parte dei tuoi dipendenti e appaltatori è uno scopo consentito dalla FSL: non è richiesta alcuna licenza. È necessaria una licenza commerciale quando rendi OmniVoice disponibile ad altri come parte di un prodotto o servizio concorrente (rivendita, SaaS in hosting, white label).",
|
||||
"a_internal_tools": "No. L'uso da parte di dipendenti e collaboratori — inclusi modifica e self-hosting interni — è gratuito sotto AGPL-3.0. Una licenza commerciale serve solo se integri OmniVoice in un prodotto o servizio proprietario o closed-source e non vuoi rispettare gli obblighi di condivisione del codice sorgente della AGPL.",
|
||||
"q_try_before": "Posso provare prima di impegnarmi?",
|
||||
"a_try_before": "Sì. L'app completa può essere scaricata gratuitamente ed eseguita localmente per la valutazione ai sensi dell'FSL. Quando sei pronto per discutere di un'implementazione commerciale, inviaci un'e-mail e lavoreremo insieme sui dettagli.",
|
||||
"a_try_before": "Sì. L'app completa è gratuita da scaricare, eseguire e self-hostare sotto AGPL-3.0, senza alcun accordo. Quando vorrai parlare di una licenza commerciale (uso proprietario), scrivici e definiremo i dettagli insieme.",
|
||||
"q_watermark": "E la filigrana?",
|
||||
"a_watermark": "La filigrana invisibile AudioSeal è incorporata per impostazione predefinita. I licenziatari commerciali possono disabilitarlo in Impostazioni → Privacy. L'uso gratuito/personale include sempre la filigrana.",
|
||||
"q_apache": "Il sorgente diventerà mai Apache 2.0?",
|
||||
"a_apache": "Sì. Ogni versione viene convertita automaticamente nella licenza Apache, versione 2.0 nel secondo anniversario della sua pubblicazione. Ciò significa che il rilascio di oggi è Apache 2.0 tra due anni, non è richiesta alcuna azione da parte nostra: l'FSL lo garantisce irrevocabilmente."
|
||||
"a_watermark": "La filigrana invisibile AudioSeal è incorporata per impostazione predefinita per tutti. I licenziatari commerciali possono disabilitarla in Impostazioni → Privacy."
|
||||
},
|
||||
"gallery_extra": {
|
||||
"save_prompt": "Inserisci un nome per questo profilo vocale:",
|
||||
@@ -1597,5 +1596,73 @@
|
||||
"none": "Nessuna versione trovata",
|
||||
"load_error": "Impossibile caricare le versioni (offline?)",
|
||||
"retry_load": "Riprova"
|
||||
},
|
||||
"firstrun": {
|
||||
"loading": "Preparazione della configurazione…",
|
||||
"title": "Configura OmniVoice Studio",
|
||||
"subtitle": "Non è stato ancora installato nulla: controlla dove andrà tutto, poi avvia. Potrai cambiarlo nelle Impostazioni.",
|
||||
"language": "Lingua",
|
||||
"mode_title": "Modalità di installazione",
|
||||
"mode_installed": "Installata",
|
||||
"mode_installed_desc": "Usa le cartelle di sistema standard. Consigliata alla maggior parte degli utenti.",
|
||||
"mode_portable": "Portatile",
|
||||
"mode_portable_desc": "Tutto risiede in una cartella accanto all'app: spostala su un altro disco o computer come un'unica unità.",
|
||||
"mode_portable_unavailable": "Non disponibile: la cartella accanto all'app non è scrivibile.",
|
||||
"storage_title": "Archiviazione",
|
||||
"portable_folder": "Cartella portatile",
|
||||
"portable_folder_desc": "Ambiente, modelli e i tuoi dati vocali: una sola cartella, completamente spostabile.",
|
||||
"env_dir": "Ambiente dell'app",
|
||||
"env_dir_desc": "Runtime Python e librerie IA.",
|
||||
"data_dir": "Dati vocali e progetti",
|
||||
"data_dir_desc": "Le tue voci, i doppiaggi, gli output e il database dei progetti.",
|
||||
"models_dir": "Cache dei modelli",
|
||||
"models_dir_desc": "Modelli IA scaricati: la parte più grande e più facile da spostare.",
|
||||
"needs": "richiede ~{{size}}",
|
||||
"free": "{{size}} liberi",
|
||||
"checking": "controllo…",
|
||||
"not_writable": "non scrivibile",
|
||||
"change": "Cambia…",
|
||||
"compute_title": "Calcolo",
|
||||
"compute_label": "GPU / acceleratore",
|
||||
"compute_auto": "Auto (NVIDIA CUDA / Apple MPS / CPU)",
|
||||
"compute_rocm": "GPU AMD (ROCm, Linux)",
|
||||
"channel_label": "Canale di aggiornamento",
|
||||
"channel_stable": "Stabile",
|
||||
"channel_preview": "Anteprima (ultimo main)",
|
||||
"network_title": "Rete",
|
||||
"region_label": "Area di download",
|
||||
"mirrors_title": "Mirror personalizzati (avanzato)",
|
||||
"mirror_pypi": "URL indice PyPI",
|
||||
"mirror_hf": "Endpoint Hugging Face",
|
||||
"mirror_python": "Mirror download Python",
|
||||
"insufficient_space": "Spazio insufficiente: questa configurazione richiede ~{{need}} sullo stesso disco, ma sono disponibili solo {{free}}. Scegli un'altra posizione.",
|
||||
"blocked_not_writable": "Una cartella scelta non è scrivibile: scegli un'altra posizione.",
|
||||
"total_required": "Spazio totale necessario: ~{{size}} (download unico al primo utilizzo)",
|
||||
"start": "Avvia installazione",
|
||||
"starting": "Avvio…",
|
||||
"compute_detected": "Rilevato",
|
||||
"compute_match": "corrisponde a questa macchina",
|
||||
"compute_auto_desc": "Sceglie il miglior backend di questa macchina a runtime — CUDA su NVIDIA, MPS su Apple Silicon, altrimenti CPU.",
|
||||
"compute_rocm_desc": "Installa le wheel ROCm di PyTorch per le schede AMD su Linux. In caso di dubbio lascia Auto.",
|
||||
"channel_stable_desc": "Solo release testate — gli aggiornamenti arrivano dopo la convalida della community.",
|
||||
"channel_preview_desc": "Build continue dall'ultimo main — nuovi motori e fix per primi, con qualche spigolo occasionale.",
|
||||
"installing_title": "Installazione",
|
||||
"activity_title": "Attività",
|
||||
"stage_setup": "Configurazione",
|
||||
"stage_models": "Modelli e motori",
|
||||
"chip_required": "richiesto",
|
||||
"chip_optional": "opzionale",
|
||||
"chip_engine": "motore",
|
||||
"lib_download": "Scarica",
|
||||
"lib_downloading": "download…",
|
||||
"lib_use": "Usa",
|
||||
"lib_active": "attivo",
|
||||
"lib_in_settings": "installa dopo nelle Impostazioni",
|
||||
"lib_show_all": "Mostra {{count}} modelli opzionali",
|
||||
"trust_line": "Tutto gira e resta su questa macchina — niente account, niente cloud, niente telemetria.",
|
||||
"resume_note": "I download interrotti riprendono da soli — chiudere l’app è sicuro.",
|
||||
"eta_left": "~{{eta}} rimanenti",
|
||||
"first_sound_text": "Benvenuto nel tuo studio. Ogni parola che senti è stata generata su questa macchina, proprio ora.",
|
||||
"first_sound_done": "Quella voce? Generata pochi secondi fa, in locale. Benvenuto."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -978,7 +978,7 @@
|
||||
"back": "スタジオに戻る",
|
||||
"badge": "商用ライセンス",
|
||||
"hero_title": "本番環境で AI 音声を出荷",
|
||||
"hero_desc": "OmniVoice Studio は、Functional Source License (FSL) に基づいてソース提供されています。ほとんどのユーザーは、商業契約なしで評価、プロトタイプを作成し、さらには社内で導入することができます。商用ライセンスが必要になるのは、競合する製品やサービスを構築している場合、またはユースケースが FSL の境界外にある場合のみです。",
|
||||
"hero_desc": "OmniVoice Studio は GNU Affero General Public License v3(AGPL-3.0)の下で提供される自由なオープンソースソフトウェアです。商用利用や社内業務利用を含め、無料でご利用いただけます。商用ライセンスが必要になるのは、AGPL-3.0 のコピーレフト義務なしに OmniVoice Studio をクローズドソースまたはプロプライエタリな製品・サービスに組み込みたい場合のみです。",
|
||||
"why_title": "企業が OmniVoice を選ぶ理由",
|
||||
"pricing_title": "価格設定",
|
||||
"faq_title": "よくある質問",
|
||||
@@ -999,7 +999,7 @@
|
||||
"benefit_source_desc": "スタックを完全に可視化します。ライセンス条項内で監査、フォーク、適応を行います。",
|
||||
"benefit_lang": "646の言語",
|
||||
"benefit_lang_desc": "人間レベルの品質で 646 言語の文字起こし、翻訳、吹き替えを行います。",
|
||||
"hero_note": "FSL では、内部使用は大規模であっても無料です。商用ライセンスは、OmniVoice を競合製品またはサービス (ホスト型 API または従量制 API、再販またはホワイトラベル アプリ) として他者に提供する場合にのみ必要です。価格帯は近日中に公開される予定です。それまでにお問い合わせください。"
|
||||
"hero_note": "利用、セルフホスティング、商用利用はすべて AGPL-3.0 の下で無料です(大規模利用も含む)。AGPL はネットワーク・コピーレフト型ライセンスです。OmniVoice を改変し、その改変版をネットワーク経由で他者に提供する場合、改変したソースコードを同じ条件で公開する必要があります。商用ライセンスを取得すると、プロプライエタリ/クローズドソースの展開についてこのコピーレフト義務が免除されます。料金プランは近日公開予定です。それまでの間はお問い合わせください。"
|
||||
},
|
||||
"exportModal": {
|
||||
"export": "エクスポート",
|
||||
@@ -1098,7 +1098,8 @@
|
||||
"script_french": "英語以外(フランス語)",
|
||||
"aria_pause": "一時停止 {{label}}",
|
||||
"aria_hear": "{{label}} を聞いてください",
|
||||
"aria_replay": "文字起こしを通じて {{label}} を再生する"
|
||||
"aria_replay": "文字起こしを通じて {{label}} を再生する",
|
||||
"dictation_lede_hotkey_only": "デスクトップのどこでも上のショートカットを押しながら話し、離すとフォーカス中のアプリにテキストが入力されます。今押して動作を確認しましょう。"
|
||||
},
|
||||
"direction": {
|
||||
"title": "セグメント #{{id}} の方向",
|
||||
@@ -1412,13 +1413,11 @@
|
||||
},
|
||||
"enterprise_faq": {
|
||||
"q_internal_tools": "内部ツールにはライセンスが必要ですか?",
|
||||
"a_internal_tools": "従業員および請負業者による内部使用は、FSL の下で許可された目的であり、ライセンスは必要ありません。 OmniVoice を競合製品またはサービス (再販、ホスト型 SaaS、ホワイトラベル) の一部として他者が利用できるようにする場合は、商用ライセンスが必要です。",
|
||||
"a_internal_tools": "いいえ。従業員や委託先による利用(社内での改変やセルフホスティングを含む)は AGPL-3.0 の下で無料です。商用ライセンスが必要なのは、OmniVoice をクローズドソースまたはプロプライエタリな製品・サービスに組み込み、AGPL のソースコード公開義務に従いたくない場合のみです。",
|
||||
"q_try_before": "コミットする前に試してみることはできますか?",
|
||||
"a_try_before": "はい。完全なアプリは、FSL に基づいて評価のために無料でダウンロードしてローカルで実行できます。商用展開について話し合う準備ができたら、メールでご連絡ください。詳細については一緒に検討させていただきます。",
|
||||
"a_try_before": "はい。アプリ全体を AGPL-3.0 の下で無料でダウンロード・実行・セルフホストでき、契約は不要です。商用(プロプライエタリ利用)ライセンスについて相談する準備ができましたら、メールでご連絡ください。詳細を一緒に詰めていきましょう。",
|
||||
"q_watermark": "透かしについてはどうですか?",
|
||||
"a_watermark": "デフォルトでは、目に見えない AudioSeal ウォーターマークが埋め込まれています。商用ライセンシーは、「設定」→「プライバシー」で無効にすることができます。無料/個人使用には必ずウォーターマークが含まれます。",
|
||||
"q_apache": "ソースが Apache 2.0 になることはありますか?",
|
||||
"a_apache": "はい。各リリースは、公開から 2 年目に自動的に Apache License バージョン 2.0 に変換されます。つまり、今日のリリースは 2 年後の Apache 2.0 であり、私たちからのアクションは必要ありません。FSL はそれを取り消し不能に保証します。"
|
||||
"a_watermark": "目に見えない AudioSeal ウォーターマークは、デフォルトで全員に埋め込まれます。商用ライセンシーは「設定」→「プライバシー」で無効にできます。"
|
||||
},
|
||||
"gallery_extra": {
|
||||
"save_prompt": "この音声プロファイルの名前を入力してください:",
|
||||
@@ -1598,5 +1597,73 @@
|
||||
"none": "リリースが見つかりませんでした",
|
||||
"load_error": "リリースをロードできませんでした (オフライン?)",
|
||||
"retry_load": "再試行"
|
||||
},
|
||||
"firstrun": {
|
||||
"loading": "セットアップを準備中…",
|
||||
"title": "OmniVoice Studio のセットアップ",
|
||||
"subtitle": "まだ何もインストールされていません。保存先を確認してから開始してください。後で設定から変更できます。",
|
||||
"language": "言語",
|
||||
"mode_title": "インストール方式",
|
||||
"mode_installed": "標準インストール",
|
||||
"mode_installed_desc": "システム標準のフォルダーを使用します。ほとんどの方におすすめです。",
|
||||
"mode_portable": "ポータブル",
|
||||
"mode_portable_desc": "すべてをアプリの隣の 1 つのフォルダーに保存します。別のディスクや PC へまるごと移動できます。",
|
||||
"mode_portable_unavailable": "利用不可:アプリの隣のフォルダーに書き込めません。",
|
||||
"storage_title": "ストレージ",
|
||||
"portable_folder": "ポータブルフォルダー",
|
||||
"portable_folder_desc": "実行環境・モデル・音声データを 1 つのフォルダーにまとめ、丸ごと移動できます。",
|
||||
"env_dir": "アプリ実行環境",
|
||||
"env_dir_desc": "Python ランタイムと AI ライブラリ。",
|
||||
"data_dir": "音声データとプロジェクト",
|
||||
"data_dir_desc": "あなたの声、吹き替え、出力ファイル、プロジェクトのデータベース。",
|
||||
"models_dir": "モデルキャッシュ",
|
||||
"models_dir_desc": "ダウンロードした AI モデル。最も容量が大きく、移動に向いています。",
|
||||
"needs": "約 {{size}} 必要",
|
||||
"free": "空き {{size}}",
|
||||
"checking": "確認中…",
|
||||
"not_writable": "書き込み不可",
|
||||
"change": "変更…",
|
||||
"compute_title": "コンピュート",
|
||||
"compute_label": "GPU / アクセラレーター",
|
||||
"compute_auto": "自動(NVIDIA CUDA / Apple MPS / CPU)",
|
||||
"compute_rocm": "AMD GPU(ROCm、Linux)",
|
||||
"channel_label": "更新チャンネル",
|
||||
"channel_stable": "安定版",
|
||||
"channel_preview": "プレビュー(最新 main)",
|
||||
"network_title": "ネットワーク",
|
||||
"region_label": "ダウンロード地域",
|
||||
"mirrors_title": "カスタムミラー(上級者向け)",
|
||||
"mirror_pypi": "PyPI インデックス URL",
|
||||
"mirror_hf": "Hugging Face エンドポイント",
|
||||
"mirror_python": "Python ダウンロードミラー",
|
||||
"insufficient_space": "空き容量が不足しています:この構成は同一ディスクに約 {{need}} 必要ですが、{{free}} しかありません。別の場所を選んでください。",
|
||||
"blocked_not_writable": "選択したフォルダーに書き込めません。別の場所を選んでください。",
|
||||
"total_required": "必要なディスク容量:約 {{size}}(初回のみダウンロード)",
|
||||
"start": "インストールを開始",
|
||||
"starting": "開始しています…",
|
||||
"compute_detected": "検出済み",
|
||||
"compute_match": "このマシンに一致",
|
||||
"compute_auto_desc": "実行時にこのマシン最適のバックエンドを選択します。NVIDIA は CUDA、Apple シリコンは MPS、それ以外は CPU。",
|
||||
"compute_rocm_desc": "Linux 上の AMD GPU 向けに PyTorch ROCm ホイールをインストールします。不明な場合は「自動」のままに。",
|
||||
"channel_stable_desc": "テスト済みリリースのみ。コミュニティ検証後に更新が届きます。",
|
||||
"channel_preview_desc": "最新 main のローリングビルド。新エンジンや修正をいち早く入手できますが、粗削りな場合があります。",
|
||||
"installing_title": "インストール中",
|
||||
"activity_title": "アクティビティ",
|
||||
"stage_setup": "セットアップ",
|
||||
"stage_models": "モデルとエンジン",
|
||||
"chip_required": "必須",
|
||||
"chip_optional": "任意",
|
||||
"chip_engine": "エンジン",
|
||||
"lib_download": "ダウンロード",
|
||||
"lib_downloading": "ダウンロード中…",
|
||||
"lib_use": "使用",
|
||||
"lib_active": "使用中",
|
||||
"lib_in_settings": "後で設定からインストール",
|
||||
"lib_show_all": "任意モデル {{count}} 件を表示",
|
||||
"trust_line": "すべてこのマシン上で動作・保存されます。アカウント不要、クラウドなし、テレメトリーなし。",
|
||||
"resume_note": "中断したダウンロードは自動的に再開されます。アプリを閉じても安全です。",
|
||||
"eta_left": "残り約 {{eta}}",
|
||||
"first_sound_text": "あなたのスタジオへようこそ。いま聞こえている言葉はすべて、たった今このマシンで生成されたものです。",
|
||||
"first_sound_done": "この声、数秒前にローカルで生成されました。ようこそ。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -977,7 +977,7 @@
|
||||
"back": "스튜디오로 돌아가기",
|
||||
"badge": "상업용 라이센스",
|
||||
"hero_title": "프로덕션에 AI 음성 전달",
|
||||
"hero_desc": "OmniVoice Studio는 FSL(Functional Source License)에 따라 소스를 사용할 수 있습니다. 대부분의 사용자는 상업적 계약 없이 내부적으로 평가, 프로토타입 작성 및 배포까지 할 수 있습니다. 경쟁 제품이나 서비스를 구축하는 경우 또는 사용 사례가 FSL의 경계를 벗어나는 경우에만 상업용 라이선스가 필요합니다.",
|
||||
"hero_desc": "OmniVoice Studio는 GNU Affero General Public License v3(AGPL-3.0)에 따라 제공되는 자유 오픈소스 소프트웨어입니다. 상업적 이용과 사내 업무 이용을 포함해 무료로 사용할 수 있습니다. 상업용 라이선스는 AGPL-3.0의 카피레프트 의무 없이 OmniVoice Studio를 클로즈드 소스 또는 독점 제품·서비스에 포함하려는 경우에만 필요합니다.",
|
||||
"why_title": "기업이 OmniVoice를 선택하는 이유",
|
||||
"pricing_title": "가격",
|
||||
"faq_title": "일반적인 질문",
|
||||
@@ -998,7 +998,7 @@
|
||||
"benefit_source_desc": "스택에 대한 완전한 가시성. 라이선스 조건 내에서 감사, 분기 및 조정합니다.",
|
||||
"benefit_lang": "646개 언어",
|
||||
"benefit_lang_desc": "인간 수준의 품질로 646개 언어로 전사, 번역, 더빙하세요.",
|
||||
"hero_note": "내부 사용은 규모에 상관없이 FSL에 따라 무료입니다. 상업용 라이선스는 OmniVoice를 경쟁 제품이나 서비스(호스팅 또는 종량제 API, 재판매 또는 화이트 라벨 앱)로 다른 사람에게 제공하는 데만 필요합니다. 가격 등급이 곧 제공될 예정입니다. 그동안 연락해 주세요."
|
||||
"hero_note": "사용, 자체 호스팅, 상업적 이용 모두 AGPL-3.0에 따라 무료이며, 대규모 사용도 마찬가지입니다. AGPL은 네트워크 카피레프트 라이선스입니다. OmniVoice를 수정하여 그 수정 버전을 네트워크를 통해 다른 사람에게 제공하는 경우, 동일한 조건으로 수정된 소스 코드를 공개해야 합니다. 상업용 라이선스를 구매하면 독점·클로즈드 소스 배포에 대해 이러한 카피레프트 의무가 면제됩니다. 가격 정책은 곧 공개됩니다. 그동안은 문의해 주세요."
|
||||
},
|
||||
"exportModal": {
|
||||
"export": "수출",
|
||||
@@ -1097,7 +1097,8 @@
|
||||
"script_french": "비영어권(프랑스어)",
|
||||
"aria_pause": "일시중지 {{label}}",
|
||||
"aria_hear": "{{label}} 듣기",
|
||||
"aria_replay": "전사기를 통해 {{label}} 재생"
|
||||
"aria_replay": "전사기를 통해 {{label}} 재생",
|
||||
"dictation_lede_hotkey_only": "데스크톱 어디서든 위 단축키를 누른 채 말하고 떼면 포커스된 앱에 텍스트가 입력됩니다. 지금 눌러서 확인해 보세요."
|
||||
},
|
||||
"direction": {
|
||||
"title": "세그먼트 #{{id}} 방향",
|
||||
@@ -1411,13 +1412,11 @@
|
||||
},
|
||||
"enterprise_faq": {
|
||||
"q_internal_tools": "내부 도구에 대한 라이선스가 필요합니까?",
|
||||
"a_internal_tools": "직원 및 계약자의 내부 사용은 FSL에 따라 허용된 목적이며 라이선스가 필요하지 않습니다. OmniVoice를 경쟁 제품 또는 서비스(재판매, 호스팅 SaaS, 화이트 라벨)의 일부로 다른 사람에게 제공하려면 상용 라이선스가 필요합니다.",
|
||||
"a_internal_tools": "아니요. 직원과 계약자의 사용은 — 내부 수정 및 자체 호스팅을 포함해 — AGPL-3.0에 따라 무료입니다. 상업용 라이선스는 OmniVoice를 클로즈드 소스 또는 독점 제품·서비스에 포함하면서 AGPL의 소스 공개 의무를 따르고 싶지 않은 경우에만 필요합니다.",
|
||||
"q_try_before": "커밋하기 전에 시도해 볼 수 있나요?",
|
||||
"a_try_before": "그렇습니다. FSL에 따른 평가를 위해 전체 앱을 무료로 다운로드하고 로컬에서 실행할 수 있습니다. 상용 배포에 대해 논의할 준비가 되면 이메일을 보내주시면 세부 사항을 함께 논의해 보겠습니다.",
|
||||
"a_try_before": "예. 전체 앱은 AGPL-3.0에 따라 무료로 다운로드, 실행, 자체 호스팅할 수 있으며 별도의 계약이 필요 없습니다. 상업용(독점 사용) 라이선스에 대해 논의할 준비가 되면 이메일로 연락해 주세요. 함께 세부 사항을 조율하겠습니다.",
|
||||
"q_watermark": "워터마크는 어떻습니까?",
|
||||
"a_watermark": "보이지 않는 AudioSeal 워터마크가 기본적으로 내장되어 있습니다. 상업용 라이선스 사용자는 설정 → 개인정보 보호에서 이를 비활성화할 수 있습니다. 무료/개인 사용에는 항상 워터마크가 포함됩니다.",
|
||||
"q_apache": "소스가 Apache 2.0이 됩니까?",
|
||||
"a_apache": "그렇습니다. 각 릴리스는 출판 2주년이 되는 날 자동으로 Apache 라이센스 버전 2.0으로 변환됩니다. 즉, 오늘의 릴리스는 2년 후 Apache 2.0이며 우리가 취할 조치는 없습니다. FSL은 이를 취소할 수 없게 보장합니다."
|
||||
"a_watermark": "보이지 않는 AudioSeal 워터마크는 기본적으로 모든 사용자에게 내장됩니다. 상업용 라이선스 사용자는 설정 → 개인정보 보호에서 비활성화할 수 있습니다."
|
||||
},
|
||||
"gallery_extra": {
|
||||
"save_prompt": "이 음성 프로필의 이름을 입력하세요.",
|
||||
@@ -1597,5 +1596,73 @@
|
||||
"none": "릴리스를 찾을 수 없습니다.",
|
||||
"load_error": "릴리스를 로드할 수 없습니다(오프라인?)",
|
||||
"retry_load": "재시도"
|
||||
},
|
||||
"firstrun": {
|
||||
"loading": "설정 준비 중…",
|
||||
"title": "OmniVoice Studio 설정",
|
||||
"subtitle": "아직 아무것도 설치되지 않았습니다. 저장 위치를 확인한 뒤 시작하세요. 나중에 설정에서 변경할 수 있습니다.",
|
||||
"language": "언어",
|
||||
"mode_title": "설치 방식",
|
||||
"mode_installed": "일반 설치",
|
||||
"mode_installed_desc": "시스템 표준 폴더를 사용합니다. 대부분의 사용자에게 권장됩니다.",
|
||||
"mode_portable": "포터블",
|
||||
"mode_portable_desc": "모든 것이 앱 옆의 폴더 하나에 저장됩니다. 다른 디스크나 PC로 통째로 옮길 수 있습니다.",
|
||||
"mode_portable_unavailable": "사용 불가: 앱 옆 폴더에 쓸 수 없습니다.",
|
||||
"storage_title": "저장소",
|
||||
"portable_folder": "포터블 폴더",
|
||||
"portable_folder_desc": "실행 환경, 모델, 음성 데이터를 폴더 하나에 담아 통째로 이동할 수 있습니다.",
|
||||
"env_dir": "앱 실행 환경",
|
||||
"env_dir_desc": "Python 런타임과 AI 라이브러리.",
|
||||
"data_dir": "음성 데이터 및 프로젝트",
|
||||
"data_dir_desc": "내 목소리, 더빙, 출력물, 프로젝트 데이터베이스.",
|
||||
"models_dir": "모델 캐시",
|
||||
"models_dir_desc": "다운로드한 AI 모델 — 가장 크고 옮기기 쉬운 부분입니다.",
|
||||
"needs": "약 {{size}} 필요",
|
||||
"free": "{{size}} 남음",
|
||||
"checking": "확인 중…",
|
||||
"not_writable": "쓰기 불가",
|
||||
"change": "변경…",
|
||||
"compute_title": "연산",
|
||||
"compute_label": "GPU / 가속기",
|
||||
"compute_auto": "자동 (NVIDIA CUDA / Apple MPS / CPU)",
|
||||
"compute_rocm": "AMD GPU (ROCm, Linux)",
|
||||
"channel_label": "업데이트 채널",
|
||||
"channel_stable": "안정",
|
||||
"channel_preview": "프리뷰 (최신 main)",
|
||||
"network_title": "네트워크",
|
||||
"region_label": "다운로드 지역",
|
||||
"mirrors_title": "사용자 지정 미러 (고급)",
|
||||
"mirror_pypi": "PyPI 인덱스 URL",
|
||||
"mirror_hf": "Hugging Face 엔드포인트",
|
||||
"mirror_python": "Python 다운로드 미러",
|
||||
"insufficient_space": "공간 부족: 이 구성은 한 디스크에 약 {{need}}가 필요하지만 {{free}}만 남아 있습니다. 다른 위치를 선택하세요.",
|
||||
"blocked_not_writable": "선택한 폴더에 쓸 수 없습니다. 다른 위치를 선택하세요.",
|
||||
"total_required": "총 필요 공간: 약 {{size}} (최초 1회 다운로드)",
|
||||
"start": "설치 시작",
|
||||
"starting": "시작 중…",
|
||||
"compute_detected": "감지됨",
|
||||
"compute_match": "이 컴퓨터와 일치",
|
||||
"compute_auto_desc": "실행 시 이 컴퓨터에 가장 적합한 백엔드를 선택합니다. NVIDIA는 CUDA, Apple Silicon은 MPS, 그 외에는 CPU.",
|
||||
"compute_rocm_desc": "Linux의 AMD 그래픽 카드용 PyTorch ROCm 휠을 설치합니다. 잘 모르면 자동으로 두세요.",
|
||||
"channel_stable_desc": "테스트된 릴리스만 — 커뮤니티 검증 후 업데이트가 도착합니다.",
|
||||
"channel_preview_desc": "최신 main의 롤링 빌드 — 새 엔진과 수정 사항을 가장 먼저 받지만 가끔 거친 부분이 있습니다.",
|
||||
"installing_title": "설치 중",
|
||||
"activity_title": "활동",
|
||||
"stage_setup": "설정",
|
||||
"stage_models": "모델 및 엔진",
|
||||
"chip_required": "필수",
|
||||
"chip_optional": "선택",
|
||||
"chip_engine": "엔진",
|
||||
"lib_download": "다운로드",
|
||||
"lib_downloading": "다운로드 중…",
|
||||
"lib_use": "사용",
|
||||
"lib_active": "사용 중",
|
||||
"lib_in_settings": "나중에 설정에서 설치",
|
||||
"lib_show_all": "선택 모델 {{count}}개 표시",
|
||||
"trust_line": "모든 것이 이 컴퓨터에서 실행되고 저장됩니다 — 계정·클라우드·텔레메트리 없음.",
|
||||
"resume_note": "중단된 다운로드는 자동으로 이어집니다 — 앱을 닫아도 안전합니다.",
|
||||
"eta_left": "약 {{eta}} 남음",
|
||||
"first_sound_text": "당신의 스튜디오에 오신 것을 환영합니다. 지금 들리는 모든 말은 방금 이 컴퓨터에서 생성되었습니다.",
|
||||
"first_sound_done": "방금 그 목소리, 몇 초 전 로컬에서 생성됐습니다. 환영합니다."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -977,7 +977,7 @@
|
||||
"back": "Terug naar Studio",
|
||||
"badge": "Commerciële licentie",
|
||||
"hero_title": "Verzend AI-stemmen in productie",
|
||||
"hero_desc": "OmniVoice Studio is bron-beschikbaar onder de Functionele Bronlicentie (FSL). De meeste gebruikers kunnen zonder commerciële overeenkomst evalueren, prototypen maken en zelfs intern implementeren. U heeft alleen een commerciële licentie nodig als u een concurrerend product of dienst bouwt, of als uw gebruiksscenario buiten de grenzen van de FSL valt.",
|
||||
"hero_desc": "OmniVoice Studio is vrije en open-source software onder de GNU Affero General Public License v3 (AGPL-3.0) — gratis te gebruiken, ook voor commercieel en intern zakelijk gebruik. Een commerciële licentie is alleen nodig als je OmniVoice Studio wilt inbouwen in een closed-source of propriëtair product of dienst zonder de copyleft-verplichtingen van de AGPL-3.0.",
|
||||
"why_title": "Waarom bedrijven voor OmniVoice kiezen",
|
||||
"pricing_title": "Prijzen",
|
||||
"faq_title": "Veelgestelde vragen",
|
||||
@@ -998,7 +998,7 @@
|
||||
"benefit_source_desc": "Volledig zicht op de stapel. Audit, fork en pas aan binnen de licentievoorwaarden.",
|
||||
"benefit_lang": "646 talen",
|
||||
"benefit_lang_desc": "Transcribeer, vertaal en kopieer in 646 talen met kwaliteit op menselijk niveau.",
|
||||
"hero_note": "Intern gebruik – zelfs op grote schaal – is gratis onder de FSL; een commerciële licentie is alleen vereist om OmniVoice aan anderen aan te bieden als een concurrerend product of dienst (een gehoste of pay-per-use API, een doorverkochte of white-labeled app). Prijsniveaus volgen binnenkort. Neem in de tussentijd contact met ons op."
|
||||
"hero_note": "Gebruik, self-hosting en commercieel gebruik zijn allemaal gratis onder de AGPL-3.0 — ook op grote schaal. De AGPL is een netwerk-copyleft-licentie: als je OmniVoice aanpast en die aangepaste versie via een netwerk aan anderen aanbiedt, moet je je gewijzigde broncode onder dezelfde voorwaarden delen. Een commerciële licentie heft die copyleft-verplichtingen op voor propriëtaire closed-source-implementaties. Prijsplannen volgen binnenkort — neem in de tussentijd contact op."
|
||||
},
|
||||
"exportModal": {
|
||||
"export": "Exporteren",
|
||||
@@ -1097,7 +1097,8 @@
|
||||
"script_french": "Niet-Engels (Frans)",
|
||||
"aria_pause": "Pauze {{label}}",
|
||||
"aria_hear": "Hoor {{label}}",
|
||||
"aria_replay": "Speel {{label}} opnieuw af via de transcriber"
|
||||
"aria_replay": "Speel {{label}} opnieuw af via de transcriber",
|
||||
"dictation_lede_hotkey_only": "Houd de sneltoets hierboven overal op je bureaublad ingedrukt, spreek, laat los — de tekst belandt in de app met focus. Druk nu om te verifiëren."
|
||||
},
|
||||
"direction": {
|
||||
"title": "Richting voor segment #{{id}}",
|
||||
@@ -1411,13 +1412,11 @@
|
||||
},
|
||||
"enterprise_faq": {
|
||||
"q_internal_tools": "Heb ik een licentie nodig voor interne tools?",
|
||||
"a_internal_tools": "Intern gebruik door uw werknemers en contractanten is een toegestaan doel onder de FSL – er is geen licentie vereist. Een commerciële licentie is nodig wanneer u OmniVoice beschikbaar stelt aan anderen als onderdeel van een concurrerend product of dienst (wederverkoop, gehoste SaaS, white-label).",
|
||||
"a_internal_tools": "Nee. Gebruik door je medewerkers en contractors — inclusief intern aanpassen en self-hosten — is gratis onder de AGPL-3.0. Een commerciële licentie is alleen nodig als je OmniVoice inbouwt in een closed-source of propriëtair product of dienst en niet wilt voldoen aan de broncode-deelverplichtingen van de AGPL.",
|
||||
"q_try_before": "Kan ik het proberen voordat ik me vastleg?",
|
||||
"a_try_before": "Ja. De volledige app kan gratis worden gedownload en lokaal worden uitgevoerd voor evaluatie onder de FSL. Wanneer u klaar bent om een commerciële implementatie te bespreken, kunt u ons een e-mail sturen, zodat we samen de details kunnen doornemen.",
|
||||
"a_try_before": "Ja. De volledige app is gratis te downloaden, draaien en zelf te hosten onder de AGPL-3.0 — geen overeenkomst nodig. Wil je een commerciële licentie (propriëtair gebruik) bespreken, mail ons dan en we werken de details samen uit.",
|
||||
"q_watermark": "Hoe zit het met het watermerk?",
|
||||
"a_watermark": "Het onzichtbare AudioSeal-watermerk is standaard ingesloten. Commerciële licentiehouders kunnen dit uitschakelen via Instellingen → Privacy. Bij gratis/persoonlijk gebruik is altijd het watermerk inbegrepen.",
|
||||
"q_apache": "Wordt de bron ooit Apache 2.0?",
|
||||
"a_apache": "Ja. Elke release wordt automatisch geconverteerd naar de Apache-licentie, versie 2.0, op de tweede verjaardag van de publicatie ervan. Dat betekent dat de release van vandaag over twee jaar Apache 2.0 is, zonder dat er actie van ons nodig is; de FSL garandeert dit onherroepelijk."
|
||||
"a_watermark": "Het onzichtbare AudioSeal-watermerk is standaard voor iedereen ingesloten. Commerciële licentiehouders kunnen dit uitschakelen via Instellingen → Privacy."
|
||||
},
|
||||
"gallery_extra": {
|
||||
"save_prompt": "Voer een naam in voor dit stemprofiel:",
|
||||
@@ -1597,5 +1596,73 @@
|
||||
"none": "Geen releases gevonden",
|
||||
"load_error": "Kan releases niet laden (offline?)",
|
||||
"retry_load": "Opnieuw proberen"
|
||||
},
|
||||
"firstrun": {
|
||||
"loading": "Installatie voorbereiden…",
|
||||
"title": "OmniVoice Studio instellen",
|
||||
"subtitle": "Er is nog niets geïnstalleerd — controleer waar alles komt te staan en start daarna. Later te wijzigen in Instellingen.",
|
||||
"language": "Taal",
|
||||
"mode_title": "Installatiemodus",
|
||||
"mode_installed": "Geïnstalleerd",
|
||||
"mode_installed_desc": "Gebruikt de standaard systeemmappen. Aanbevolen voor de meeste gebruikers.",
|
||||
"mode_portable": "Draagbaar",
|
||||
"mode_portable_desc": "Alles staat in één map naast de app — verplaats hem als geheel naar een andere schijf of machine.",
|
||||
"mode_portable_unavailable": "Niet beschikbaar: de map naast de app is niet beschrijfbaar.",
|
||||
"storage_title": "Opslag",
|
||||
"portable_folder": "Draagbare map",
|
||||
"portable_folder_desc": "Omgeving, modellen en je stemdata — één map, volledig verplaatsbaar.",
|
||||
"env_dir": "App-omgeving",
|
||||
"env_dir_desc": "Python-runtime en AI-bibliotheken.",
|
||||
"data_dir": "Stemdata & projecten",
|
||||
"data_dir_desc": "Je stemmen, dubs, uitvoer en de projectdatabase.",
|
||||
"models_dir": "Modelcache",
|
||||
"models_dir_desc": "Gedownloade AI-modellen — het grootste en best verplaatsbare deel.",
|
||||
"needs": "vereist ~{{size}}",
|
||||
"free": "{{size}} vrij",
|
||||
"checking": "controleren…",
|
||||
"not_writable": "niet beschrijfbaar",
|
||||
"change": "Wijzigen…",
|
||||
"compute_title": "Rekenkracht",
|
||||
"compute_label": "GPU / versneller",
|
||||
"compute_auto": "Auto (NVIDIA CUDA / Apple MPS / CPU)",
|
||||
"compute_rocm": "AMD GPU (ROCm, Linux)",
|
||||
"channel_label": "Updatekanaal",
|
||||
"channel_stable": "Stabiel",
|
||||
"channel_preview": "Preview (nieuwste main)",
|
||||
"network_title": "Netwerk",
|
||||
"region_label": "Downloadregio",
|
||||
"mirrors_title": "Eigen mirrors (geavanceerd)",
|
||||
"mirror_pypi": "PyPI-index-URL",
|
||||
"mirror_hf": "Hugging Face-endpoint",
|
||||
"mirror_python": "Python-downloadmirror",
|
||||
"insufficient_space": "Onvoldoende ruimte: deze indeling vereist ~{{need}} op één schijf, slechts {{free}} beschikbaar. Kies een andere locatie.",
|
||||
"blocked_not_writable": "Een gekozen map is niet beschrijfbaar — kies een andere locatie.",
|
||||
"total_required": "Totaal benodigde schijfruimte: ~{{size}} (eenmalige download bij eerste gebruik)",
|
||||
"start": "Installatie starten",
|
||||
"starting": "Starten…",
|
||||
"compute_detected": "Gedetecteerd",
|
||||
"compute_match": "past bij deze machine",
|
||||
"compute_auto_desc": "Kiest tijdens runtime de beste backend van deze machine — CUDA op NVIDIA, MPS op Apple Silicon, anders CPU.",
|
||||
"compute_rocm_desc": "Installeert PyTorch ROCm-wheels voor AMD-kaarten op Linux. Laat op Auto staan bij twijfel.",
|
||||
"channel_stable_desc": "Alleen geteste releases — updates komen na validatie door de community.",
|
||||
"channel_preview_desc": "Doorlopende builds van de nieuwste main — nieuwe engines en fixes eerst, af en toe een ruw randje.",
|
||||
"installing_title": "Installeren",
|
||||
"activity_title": "Activiteit",
|
||||
"stage_setup": "Installatie",
|
||||
"stage_models": "Modellen & engines",
|
||||
"chip_required": "vereist",
|
||||
"chip_optional": "optioneel",
|
||||
"chip_engine": "engine",
|
||||
"lib_download": "Downloaden",
|
||||
"lib_downloading": "downloaden…",
|
||||
"lib_use": "Gebruik",
|
||||
"lib_active": "actief",
|
||||
"lib_in_settings": "later installeren in Instellingen",
|
||||
"lib_show_all": "Toon {{count}} optionele modellen",
|
||||
"trust_line": "Alles draait en blijft op deze machine — geen account, geen cloud, geen telemetrie.",
|
||||
"resume_note": "Onderbroken downloads hervatten automatisch — de app sluiten is veilig.",
|
||||
"eta_left": "nog ~{{eta}}",
|
||||
"first_sound_text": "Welkom in je studio. Elk woord dat je hoort is zojuist op deze machine gegenereerd.",
|
||||
"first_sound_done": "Die stem? Seconden geleden lokaal gegenereerd. Welkom."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -977,7 +977,7 @@
|
||||
"back": "Powrót do Studia",
|
||||
"badge": "Licencja komercyjna",
|
||||
"hero_title": "Wysyłaj głosy AI w produkcji",
|
||||
"hero_desc": "OmniVoice Studio jest dostępne w formie źródłowej na podstawie licencji Functional Source License (FSL). Większość użytkowników może oceniać, tworzyć prototypy, a nawet wdrażać wewnętrznie bez umowy komercyjnej. Licencja komercyjna jest Ci potrzebna tylko wtedy, gdy budujesz konkurencyjny produkt lub usługę lub jeśli Twój przypadek użycia wykracza poza granice FSL.",
|
||||
"hero_desc": "OmniVoice Studio to wolne i otwarte oprogramowanie na licencji GNU Affero General Public License v3 (AGPL-3.0) — bezpłatne w użyciu, także komercyjnym i wewnętrznym firmowym. Licencja komercyjna jest potrzebna tylko wtedy, gdy chcesz osadzić OmniVoice Studio w zamkniętym lub własnościowym produkcie bądź usłudze bez zobowiązań copyleft licencji AGPL-3.0.",
|
||||
"why_title": "Dlaczego firmy wybierają OmniVoice",
|
||||
"pricing_title": "Ceny",
|
||||
"faq_title": "Często zadawane pytania",
|
||||
@@ -998,7 +998,7 @@
|
||||
"benefit_source_desc": "Pełna widoczność stosu. Audytuj, rozwidlaj i dostosowuj zgodnie z warunkami licencji.",
|
||||
"benefit_lang": "646 języków",
|
||||
"benefit_lang_desc": "Transkrypcja, tłumaczenie i kopiowanie w 646 językach z jakością na poziomie ludzkim.",
|
||||
"hero_note": "Do użytku wewnętrznego — nawet na dużą skalę — jest bezpłatne w ramach FSL; licencja komercyjna jest wymagana jedynie do oferowania OmniVoice innym jako konkurencyjny produkt lub usługa (interfejs API hostowany lub płatny za użycie, aplikacja odsprzedawana lub z białą etykietą). Poziomy cenowe już wkrótce — skontaktuj się z nami w międzyczasie."
|
||||
"hero_note": "Użytkowanie, self-hosting i zastosowania komercyjne są bezpłatne na licencji AGPL-3.0 — również na dużą skalę. AGPL to licencja copyleft działająca także przez sieć: jeśli zmodyfikujesz OmniVoice i udostępnisz tę zmodyfikowaną wersję innym przez sieć, musisz udostępnić swój zmodyfikowany kod źródłowy na tych samych warunkach. Licencja komercyjna znosi te zobowiązania copyleft dla wdrożeń własnościowych o zamkniętym kodzie. Cenniki pojawią się wkrótce — w międzyczasie napisz do nas."
|
||||
},
|
||||
"exportModal": {
|
||||
"export": "Eksportuj",
|
||||
@@ -1097,7 +1097,8 @@
|
||||
"script_french": "Język inny niż angielski (francuski)",
|
||||
"aria_pause": "Wstrzymaj {{label}}",
|
||||
"aria_hear": "Usłysz {{label}}",
|
||||
"aria_replay": "Odtwórz ponownie {{label}} przez transkrypcję"
|
||||
"aria_replay": "Odtwórz ponownie {{label}} przez transkrypcję",
|
||||
"dictation_lede_hotkey_only": "Przytrzymaj powyższy skrót w dowolnym miejscu pulpitu, mów i puść — tekst trafi do aktywnej aplikacji. Naciśnij teraz, aby zweryfikować."
|
||||
},
|
||||
"direction": {
|
||||
"title": "Kierunek odcinka #{{id}}",
|
||||
@@ -1411,13 +1412,11 @@
|
||||
},
|
||||
"enterprise_faq": {
|
||||
"q_internal_tools": "Czy potrzebuję licencji na narzędzia wewnętrzne?",
|
||||
"a_internal_tools": "Wewnętrzne użycie przez Twoich pracowników i wykonawców jest celem dozwolonym w ramach FSL – nie jest wymagana żadna licencja. Licencja komercyjna jest wymagana, jeśli udostępniasz OmniVoice innym jako część konkurencyjnego produktu lub usługi (odsprzedaż, hostowany SaaS, white-label).",
|
||||
"a_internal_tools": "Nie. Użycie przez Twoich pracowników i współpracowników — w tym wewnętrzne modyfikacje i self-hosting — jest bezpłatne na licencji AGPL-3.0. Licencja komercyjna jest potrzebna tylko wtedy, gdy osadzasz OmniVoice w zamkniętym lub własnościowym produkcie bądź usłudze i nie chcesz spełniać wymogów AGPL dotyczących udostępniania kodu źródłowego.",
|
||||
"q_try_before": "Czy mogę spróbować przed zatwierdzeniem?",
|
||||
"a_try_before": "Tak. Pełną aplikację można pobrać bezpłatnie i uruchomić lokalnie w celu oceny w ramach licencji FSL. Kiedy będziesz gotowy, aby omówić wdrożenie komercyjne, napisz do nas e-mail, a wspólnie omówimy szczegóły.",
|
||||
"a_try_before": "Tak. Pełną aplikację można bezpłatnie pobrać, uruchomić i hostować samodzielnie na licencji AGPL-3.0 — bez żadnej umowy. Gdy zechcesz porozmawiać o licencji komercyjnej (użycie własnościowe), napisz do nas, a wspólnie ustalimy szczegóły.",
|
||||
"q_watermark": "A co ze znakiem wodnym?",
|
||||
"a_watermark": "Domyślnie osadzony jest niewidoczny znak wodny AudioSeal. Licencjobiorcy komercyjni mogą wyłączyć tę funkcję w Ustawieniach → Prywatność. Do użytku bezpłatnego/osobistego zawsze dołączany jest znak wodny.",
|
||||
"q_apache": "Czy źródłem kiedykolwiek stał się Apache 2.0?",
|
||||
"a_apache": "Tak. Każde wydanie jest automatycznie konwertowane do licencji Apache w wersji 2.0 w drugą rocznicę jego publikacji. Oznacza to, że dzisiejszą wersją będzie Apache 2.0 dostępny za dwa lata i nie wymaga to od nas żadnych działań — FSL gwarantuje to nieodwołalnie."
|
||||
"a_watermark": "Niewidoczny znak wodny AudioSeal jest domyślnie osadzany dla wszystkich. Licencjobiorcy komercyjni mogą go wyłączyć w Ustawieniach → Prywatność."
|
||||
},
|
||||
"gallery_extra": {
|
||||
"save_prompt": "Wprowadź nazwę tego profilu głosowego:",
|
||||
@@ -1597,5 +1596,73 @@
|
||||
"none": "Nie znaleziono żadnych wydań",
|
||||
"load_error": "Nie można wczytać wersji (offline?)",
|
||||
"retry_load": "Spróbuj ponownie"
|
||||
},
|
||||
"firstrun": {
|
||||
"loading": "Przygotowywanie konfiguracji…",
|
||||
"title": "Skonfiguruj OmniVoice Studio",
|
||||
"subtitle": "Nic nie zostało jeszcze zainstalowane — sprawdź, gdzie wszystko trafi, a potem rozpocznij. Można to później zmienić w Ustawieniach.",
|
||||
"language": "Język",
|
||||
"mode_title": "Tryb instalacji",
|
||||
"mode_installed": "Zainstalowana",
|
||||
"mode_installed_desc": "Używa standardowych folderów systemowych. Zalecane dla większości.",
|
||||
"mode_portable": "Przenośna",
|
||||
"mode_portable_desc": "Wszystko mieści się w jednym folderze obok aplikacji — przenieś go na inny dysk lub komputer w całości.",
|
||||
"mode_portable_unavailable": "Niedostępne: folder obok aplikacji nie jest zapisywalny.",
|
||||
"storage_title": "Pamięć",
|
||||
"portable_folder": "Folder przenośny",
|
||||
"portable_folder_desc": "Środowisko, modele i dane głosowe — jeden folder, w pełni przenośny.",
|
||||
"env_dir": "Środowisko aplikacji",
|
||||
"env_dir_desc": "Środowisko Python i biblioteki AI.",
|
||||
"data_dir": "Dane głosowe i projekty",
|
||||
"data_dir_desc": "Twoje głosy, dubbingi, pliki wyjściowe i baza projektów.",
|
||||
"models_dir": "Pamięć podręczna modeli",
|
||||
"models_dir_desc": "Pobrane modele AI — największa i najłatwiejsza do przeniesienia część.",
|
||||
"needs": "potrzebuje ~{{size}}",
|
||||
"free": "{{size}} wolne",
|
||||
"checking": "sprawdzanie…",
|
||||
"not_writable": "brak zapisu",
|
||||
"change": "Zmień…",
|
||||
"compute_title": "Obliczenia",
|
||||
"compute_label": "GPU / akcelerator",
|
||||
"compute_auto": "Auto (NVIDIA CUDA / Apple MPS / CPU)",
|
||||
"compute_rocm": "GPU AMD (ROCm, Linux)",
|
||||
"channel_label": "Kanał aktualizacji",
|
||||
"channel_stable": "Stabilny",
|
||||
"channel_preview": "Podgląd (najnowszy main)",
|
||||
"network_title": "Sieć",
|
||||
"region_label": "Region pobierania",
|
||||
"mirrors_title": "Własne serwery lustrzane (zaawansowane)",
|
||||
"mirror_pypi": "URL indeksu PyPI",
|
||||
"mirror_hf": "Endpoint Hugging Face",
|
||||
"mirror_python": "Mirror pobierania Pythona",
|
||||
"insufficient_space": "Za mało miejsca: ten układ wymaga ~{{need}} na jednym dysku, dostępne tylko {{free}}. Wybierz inną lokalizację.",
|
||||
"blocked_not_writable": "Wybrany folder nie jest zapisywalny — wybierz inną lokalizację.",
|
||||
"total_required": "Łącznie potrzeba: ~{{size}} (jednorazowe pobranie przy pierwszym użyciu)",
|
||||
"start": "Rozpocznij instalację",
|
||||
"starting": "Uruchamianie…",
|
||||
"compute_detected": "Wykryto",
|
||||
"compute_match": "pasuje do tej maszyny",
|
||||
"compute_auto_desc": "Wybiera najlepszy backend tej maszyny w czasie działania — CUDA na NVIDIA, MPS na Apple Silicon, inaczej CPU.",
|
||||
"compute_rocm_desc": "Instaluje pakiety PyTorch ROCm dla kart AMD w Linuksie. W razie wątpliwości zostaw Auto.",
|
||||
"channel_stable_desc": "Tylko przetestowane wydania — aktualizacje przychodzą po walidacji społeczności.",
|
||||
"channel_preview_desc": "Kroczące kompilacje z najnowszego main — nowe silniki i poprawki najpierw, czasem drobne niedociągnięcia.",
|
||||
"installing_title": "Instalowanie",
|
||||
"activity_title": "Aktywność",
|
||||
"stage_setup": "Konfiguracja",
|
||||
"stage_models": "Modele i silniki",
|
||||
"chip_required": "wymagany",
|
||||
"chip_optional": "opcjonalny",
|
||||
"chip_engine": "silnik",
|
||||
"lib_download": "Pobierz",
|
||||
"lib_downloading": "pobieranie…",
|
||||
"lib_use": "Użyj",
|
||||
"lib_active": "aktywny",
|
||||
"lib_in_settings": "zainstaluj później w Ustawieniach",
|
||||
"lib_show_all": "Pokaż opcjonalne modele: {{count}}",
|
||||
"trust_line": "Wszystko działa i pozostaje na tym komputerze — bez konta, chmury i telemetrii.",
|
||||
"resume_note": "Przerwane pobierania wznawiają się same — zamknięcie aplikacji jest bezpieczne.",
|
||||
"eta_left": "pozostało ~{{eta}}",
|
||||
"first_sound_text": "Witaj w swoim studiu. Każde słowo, które słyszysz, zostało wygenerowane na tym komputerze — przed chwilą.",
|
||||
"first_sound_done": "Ten głos? Wygenerowany sekundy temu, lokalnie. Witaj."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -977,7 +977,7 @@
|
||||
"back": "De volta ao estúdio",
|
||||
"badge": "Licença Comercial",
|
||||
"hero_title": "Envie vozes de IA em produção",
|
||||
"hero_desc": "OmniVoice Studio está disponível sob a Licença de Fonte Funcional (FSL). A maioria dos usuários pode avaliar, criar protótipos e até implantar internamente sem um acordo comercial. Você só precisa de uma licença comercial se estiver construindo um produto ou serviço concorrente ou se seu caso de uso estiver fora dos limites do FSL.",
|
||||
"hero_desc": "O OmniVoice Studio é um software livre e de código aberto sob a GNU Affero General Public License v3 (AGPL-3.0) — gratuito para qualquer uso, incluindo uso comercial e empresarial interno. Uma licença comercial só é necessária se você quiser incorporar o OmniVoice Studio em um produto ou serviço proprietário ou de código fechado sem as obrigações copyleft da AGPL-3.0.",
|
||||
"why_title": "Por que as empresas escolhem OmniVoice",
|
||||
"pricing_title": "Preços",
|
||||
"faq_title": "Perguntas comuns",
|
||||
@@ -998,7 +998,7 @@
|
||||
"benefit_source_desc": "Visibilidade total da pilha. Audite, bifurque e adapte-se aos termos da licença.",
|
||||
"benefit_lang": "646 idiomas",
|
||||
"benefit_lang_desc": "Transcreva, traduza e duble em 646 idiomas com qualidade de nível humano.",
|
||||
"hero_note": "O uso interno – mesmo em grande escala – é gratuito sob o FSL; uma licença comercial só é necessária para oferecer o OmniVoice a terceiros como um produto ou serviço concorrente (uma API hospedada ou paga por uso, um aplicativo revendido ou com etiqueta branca). Os níveis de preços estarão disponíveis em breve. Entre em contato enquanto isso."
|
||||
"hero_note": "Uso, auto-hospedagem e uso comercial são gratuitos sob a AGPL-3.0 — inclusive em grande escala. A AGPL é uma licença copyleft de rede: se você modificar o OmniVoice e oferecer essa versão modificada a terceiros pela rede, deverá compartilhar seu código-fonte modificado sob os mesmos termos. Uma licença comercial remove essas obrigações copyleft para implantações proprietárias de código fechado. Os planos de preços chegam em breve — entre em contato enquanto isso."
|
||||
},
|
||||
"exportModal": {
|
||||
"export": "Exportar",
|
||||
@@ -1097,7 +1097,8 @@
|
||||
"script_french": "Não Inglês (Francês)",
|
||||
"aria_pause": "Pausa {{label}}",
|
||||
"aria_hear": "Ouça {{label}}",
|
||||
"aria_replay": "Reproduzir {{label}} através do transcritor"
|
||||
"aria_replay": "Reproduzir {{label}} através do transcritor",
|
||||
"dictation_lede_hotkey_only": "Segure o atalho acima em qualquer lugar da área de trabalho, fale e solte — o texto cai no app em foco. Pressione agora para verificar."
|
||||
},
|
||||
"direction": {
|
||||
"title": "Direção para o segmento #{{id}}",
|
||||
@@ -1411,13 +1412,11 @@
|
||||
},
|
||||
"enterprise_faq": {
|
||||
"q_internal_tools": "Preciso de uma licença para ferramentas internas?",
|
||||
"a_internal_tools": "O uso interno por seus funcionários e contratados é uma Finalidade Permitida pela FSL — não é necessária licença. Uma licença comercial é necessária quando você disponibiliza o OmniVoice para terceiros como parte de um produto ou serviço concorrente (revenda, SaaS hospedado, marca branca).",
|
||||
"a_internal_tools": "Não. O uso por seus funcionários e contratados — incluindo modificação e auto-hospedagem internas — é gratuito sob a AGPL-3.0. Uma licença comercial só é necessária se você incorporar o OmniVoice em um produto ou serviço proprietário ou de código fechado e não quiser cumprir as obrigações de compartilhamento de código-fonte da AGPL.",
|
||||
"q_try_before": "Posso tentar antes de me comprometer?",
|
||||
"a_try_before": "Sim. O aplicativo completo pode ser baixado gratuitamente e executado localmente para avaliação no FSL. Quando você estiver pronto para discutir uma implantação comercial, envie-nos um e-mail e trabalharemos juntos nos detalhes.",
|
||||
"a_try_before": "Sim. O aplicativo completo é gratuito para baixar, executar e auto-hospedar sob a AGPL-3.0 — nenhum acordo necessário. Quando quiser discutir uma licença comercial (uso proprietário), envie um e-mail e resolveremos os detalhes juntos.",
|
||||
"q_watermark": "E a marca d’água?",
|
||||
"a_watermark": "A marca d’água invisível AudioSeal é incorporada por padrão. Licenciados comerciais podem desativá-lo em Configurações → Privacidade. O uso gratuito/pessoal sempre inclui a marca d'água.",
|
||||
"q_apache": "A fonte alguma vez se torna Apache 2.0?",
|
||||
"a_apache": "Sim. Cada versão é convertida automaticamente para a Licença Apache, Versão 2.0, no segundo aniversário de sua publicação. Isso significa que o lançamento de hoje será o Apache 2.0 em dois anos, nenhuma ação será necessária de nossa parte — a FSL garante isso de forma irrevogável."
|
||||
"a_watermark": "A marca d'água invisível AudioSeal é incorporada por padrão para todos. Licenciados comerciais podem desativá-la em Configurações → Privacidade."
|
||||
},
|
||||
"gallery_extra": {
|
||||
"save_prompt": "Digite um nome para este perfil de voz:",
|
||||
@@ -1597,5 +1596,73 @@
|
||||
"none": "Nenhum lançamento encontrado",
|
||||
"load_error": "Não foi possível carregar as versões (off-line?)",
|
||||
"retry_load": "Tentar novamente"
|
||||
},
|
||||
"firstrun": {
|
||||
"loading": "Preparando a configuração…",
|
||||
"title": "Configurar o OmniVoice Studio",
|
||||
"subtitle": "Nada foi instalado ainda — confira onde tudo será salvo e então comece. Você pode mudar depois nas Configurações.",
|
||||
"language": "Idioma",
|
||||
"mode_title": "Modo de instalação",
|
||||
"mode_installed": "Instalado",
|
||||
"mode_installed_desc": "Usa as pastas padrão do sistema. Recomendado para a maioria.",
|
||||
"mode_portable": "Portátil",
|
||||
"mode_portable_desc": "Tudo fica em uma pasta ao lado do app — mova-a para outro disco ou máquina como uma unidade.",
|
||||
"mode_portable_unavailable": "Indisponível: a pasta ao lado do app não é gravável.",
|
||||
"storage_title": "Armazenamento",
|
||||
"portable_folder": "Pasta portátil",
|
||||
"portable_folder_desc": "Ambiente, modelos e seus dados de voz — uma pasta, totalmente móvel.",
|
||||
"env_dir": "Ambiente do aplicativo",
|
||||
"env_dir_desc": "Runtime Python e bibliotecas de IA.",
|
||||
"data_dir": "Dados de voz e projetos",
|
||||
"data_dir_desc": "Suas vozes, dublagens, saídas e o banco de dados de projetos.",
|
||||
"models_dir": "Cache de modelos",
|
||||
"models_dir_desc": "Modelos de IA baixados — a parte maior e mais fácil de realocar.",
|
||||
"needs": "precisa de ~{{size}}",
|
||||
"free": "{{size}} livres",
|
||||
"checking": "verificando…",
|
||||
"not_writable": "sem permissão de escrita",
|
||||
"change": "Alterar…",
|
||||
"compute_title": "Computação",
|
||||
"compute_label": "GPU / acelerador",
|
||||
"compute_auto": "Auto (NVIDIA CUDA / Apple MPS / CPU)",
|
||||
"compute_rocm": "GPU AMD (ROCm, Linux)",
|
||||
"channel_label": "Canal de atualização",
|
||||
"channel_stable": "Estável",
|
||||
"channel_preview": "Preview (main mais recente)",
|
||||
"network_title": "Rede",
|
||||
"region_label": "Região de download",
|
||||
"mirrors_title": "Mirrors personalizados (avançado)",
|
||||
"mirror_pypi": "URL do índice PyPI",
|
||||
"mirror_hf": "Endpoint do Hugging Face",
|
||||
"mirror_python": "Mirror de downloads do Python",
|
||||
"insufficient_space": "Espaço insuficiente: este layout precisa de ~{{need}} em um mesmo disco, e só há {{free}} disponíveis. Escolha outro local.",
|
||||
"blocked_not_writable": "Uma pasta escolhida não é gravável — escolha outro local.",
|
||||
"total_required": "Espaço total necessário: ~{{size}} (download único no primeiro uso)",
|
||||
"start": "Iniciar instalação",
|
||||
"starting": "Iniciando…",
|
||||
"compute_detected": "Detectado",
|
||||
"compute_match": "corresponde a esta máquina",
|
||||
"compute_auto_desc": "Escolhe o melhor backend desta máquina em tempo de execução — CUDA em NVIDIA, MPS em Apple Silicon, senão CPU.",
|
||||
"compute_rocm_desc": "Instala as wheels ROCm do PyTorch para placas AMD no Linux. Deixe em Auto se não tiver certeza.",
|
||||
"channel_stable_desc": "Apenas versões testadas — as atualizações chegam após validação da comunidade.",
|
||||
"channel_preview_desc": "Builds contínuas do main mais recente — novos motores e correções primeiro, com arestas ocasionais.",
|
||||
"installing_title": "Instalando",
|
||||
"activity_title": "Atividade",
|
||||
"stage_setup": "Configuração",
|
||||
"stage_models": "Modelos e motores",
|
||||
"chip_required": "obrigatório",
|
||||
"chip_optional": "opcional",
|
||||
"chip_engine": "motor",
|
||||
"lib_download": "Baixar",
|
||||
"lib_downloading": "baixando…",
|
||||
"lib_use": "Usar",
|
||||
"lib_active": "ativo",
|
||||
"lib_in_settings": "instalar depois em Configurações",
|
||||
"lib_show_all": "Mostrar {{count}} modelos opcionais",
|
||||
"trust_line": "Tudo roda e permanece nesta máquina — sem conta, sem nuvem, sem telemetria.",
|
||||
"resume_note": "Downloads interrompidos retomam sozinhos — fechar o app é seguro.",
|
||||
"eta_left": "faltam ~{{eta}}",
|
||||
"first_sound_text": "Bem-vindo ao seu estúdio. Cada palavra que você ouve foi gerada nesta máquina, agora mesmo.",
|
||||
"first_sound_done": "Essa voz? Gerada há segundos, localmente. Bem-vindo."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -977,7 +977,7 @@
|
||||
"back": "Вернуться в студию",
|
||||
"badge": "Коммерческая лицензия",
|
||||
"hero_title": "Запуск голосов ИИ в производство",
|
||||
"hero_desc": "Исходный код OmniVoice Studio доступен по лицензии Functional Source License (FSL). Большинство пользователей могут оценить, создать прототип и даже развернуть внутри компании без коммерческого соглашения. Коммерческая лицензия вам понадобится только в том случае, если вы создаете конкурирующий продукт или услугу или если ваш вариант использования выходит за рамки FSL.",
|
||||
"hero_desc": "OmniVoice Studio — свободное программное обеспечение с открытым исходным кодом под лицензией GNU Affero General Public License v3 (AGPL-3.0): бесплатно для любого использования, включая коммерческое и внутрикорпоративное. Коммерческая лицензия нужна только в том случае, если вы хотите встроить OmniVoice Studio в закрытый или проприетарный продукт или сервис без обязательств копилефта AGPL-3.0.",
|
||||
"why_title": "Почему компании выбирают OmniVoice",
|
||||
"pricing_title": "Цены",
|
||||
"faq_title": "Общие вопросы",
|
||||
@@ -998,7 +998,7 @@
|
||||
"benefit_source_desc": "Полная видимость стека. Аудит, форк и адаптация в рамках условий лицензии.",
|
||||
"benefit_lang": "646 языков",
|
||||
"benefit_lang_desc": "Транскрибируйте, переводите и дублируйте на 646 языках с качеством человеческого уровня.",
|
||||
"hero_note": "Внутреннее использование — даже в больших масштабах — бесплатно в соответствии с FSL; Коммерческая лицензия требуется только для предложения OmniVoice другим лицам в качестве конкурирующего продукта или услуги (размещенный API или API с оплатой по факту использования, перепродаваемое приложение или приложение с белой маркировкой). Ценовые уровни будут объявлены в ближайшее время — свяжитесь с нами."
|
||||
"hero_note": "Использование, самостоятельный хостинг и коммерческое применение бесплатны по лицензии AGPL-3.0 — в том числе в больших масштабах. AGPL — это лицензия с сетевым копилефтом: если вы модифицируете OmniVoice и предоставляете изменённую версию другим по сети, вы обязаны открыть свой изменённый исходный код на тех же условиях. Коммерческая лицензия снимает эти обязательства копилефта для проприетарных закрытых развертываний. Тарифы появятся в ближайшее время — а пока свяжитесь с нами."
|
||||
},
|
||||
"exportModal": {
|
||||
"export": "Экспорт",
|
||||
@@ -1097,7 +1097,8 @@
|
||||
"script_french": "Неанглийский (французский)",
|
||||
"aria_pause": "Пауза {{label}}",
|
||||
"aria_hear": "Слушайте {{label}}",
|
||||
"aria_replay": "Воспроизвести {{label}} через транскрибатор"
|
||||
"aria_replay": "Воспроизвести {{label}} через транскрибатор",
|
||||
"dictation_lede_hotkey_only": "Удерживайте сочетание клавиш выше в любом месте рабочего стола, говорите и отпустите — текст появится в активном приложении. Нажмите сейчас, чтобы проверить."
|
||||
},
|
||||
"direction": {
|
||||
"title": "Направление для сегмента №{{id}}",
|
||||
@@ -1411,13 +1412,11 @@
|
||||
},
|
||||
"enterprise_faq": {
|
||||
"q_internal_tools": "Нужна ли мне лицензия на внутренние инструменты?",
|
||||
"a_internal_tools": "Внутреннее использование вашими сотрудниками и подрядчиками является разрешенной целью согласно FSL — лицензия не требуется. Коммерческая лицензия необходима, когда вы предоставляете OmniVoice другим лицам как часть конкурирующего продукта или услуги (перепродажа, размещение SaaS, белая этикетка).",
|
||||
"a_internal_tools": "Нет. Использование вашими сотрудниками и подрядчиками — включая внутренние модификации и самостоятельный хостинг — бесплатно по лицензии AGPL-3.0. Коммерческая лицензия нужна только если вы встраиваете OmniVoice в закрытый или проприетарный продукт или сервис и не хотите выполнять требования AGPL по раскрытию исходного кода.",
|
||||
"q_try_before": "Могу ли я попробовать, прежде чем совершать?",
|
||||
"a_try_before": "Да. Полную версию приложения можно бесплатно загрузить и запустить локально для оценки в соответствии с FSL. Когда вы будете готовы обсудить коммерческое развертывание, напишите нам, и мы вместе обсудим детали.",
|
||||
"a_try_before": "Да. Полную версию приложения можно бесплатно скачать, запускать и размещать самостоятельно по лицензии AGPL-3.0 — без какого-либо договора. Когда будете готовы обсудить коммерческую лицензию (проприетарное использование), напишите нам — вместе разберём детали.",
|
||||
"q_watermark": "А что насчет водяного знака?",
|
||||
"a_watermark": "Невидимый водяной знак AudioSeal встроен по умолчанию. Обладатели коммерческих лицензий могут отключить его в «Настройки» → «Конфиденциальность». Бесплатное/личное использование всегда включает водяной знак.",
|
||||
"q_apache": "Исходный код когда-нибудь станет Apache 2.0?",
|
||||
"a_apache": "Да. Каждый выпуск автоматически преобразуется в лицензию Apache версии 2.0 во вторую годовщину его публикации. Это означает, что сегодняшний выпуск — это Apache 2.0 через два года, от нас не требуется никаких действий — FSL гарантирует это безотзывно."
|
||||
"a_watermark": "Невидимый водяной знак AudioSeal встроен по умолчанию для всех. Обладатели коммерческих лицензий могут отключить его в «Настройки» → «Конфиденциальность»."
|
||||
},
|
||||
"gallery_extra": {
|
||||
"save_prompt": "Введите имя для этого голосового профиля:",
|
||||
@@ -1597,5 +1596,73 @@
|
||||
"none": "Релизов не найдено",
|
||||
"load_error": "Не удалось загрузить выпуски (офлайн?)",
|
||||
"retry_load": "Повторить попытку"
|
||||
},
|
||||
"firstrun": {
|
||||
"loading": "Подготовка установки…",
|
||||
"title": "Настройка OmniVoice Studio",
|
||||
"subtitle": "Пока ничего не установлено — проверьте, куда всё будет сохранено, затем начните. Это можно изменить позже в настройках.",
|
||||
"language": "Язык",
|
||||
"mode_title": "Режим установки",
|
||||
"mode_installed": "Установленный",
|
||||
"mode_installed_desc": "Использует стандартные системные папки. Рекомендуется большинству.",
|
||||
"mode_portable": "Портативный",
|
||||
"mode_portable_desc": "Всё хранится в одной папке рядом с приложением — её можно целиком перенести на другой диск или компьютер.",
|
||||
"mode_portable_unavailable": "Недоступно: папка рядом с приложением недоступна для записи.",
|
||||
"storage_title": "Хранилище",
|
||||
"portable_folder": "Портативная папка",
|
||||
"portable_folder_desc": "Среда, модели и ваши голосовые данные — одна папка, полностью переносимая.",
|
||||
"env_dir": "Среда приложения",
|
||||
"env_dir_desc": "Python и библиотеки ИИ.",
|
||||
"data_dir": "Голосовые данные и проекты",
|
||||
"data_dir_desc": "Ваши голоса, дубляжи, результаты и база данных проектов.",
|
||||
"models_dir": "Кэш моделей",
|
||||
"models_dir_desc": "Скачанные модели ИИ — самая большая и легко переносимая часть.",
|
||||
"needs": "нужно ~{{size}}",
|
||||
"free": "свободно {{size}}",
|
||||
"checking": "проверка…",
|
||||
"not_writable": "нет записи",
|
||||
"change": "Изменить…",
|
||||
"compute_title": "Вычисления",
|
||||
"compute_label": "GPU / ускоритель",
|
||||
"compute_auto": "Авто (NVIDIA CUDA / Apple MPS / CPU)",
|
||||
"compute_rocm": "GPU AMD (ROCm, Linux)",
|
||||
"channel_label": "Канал обновлений",
|
||||
"channel_stable": "Стабильный",
|
||||
"channel_preview": "Предварительный (последний main)",
|
||||
"network_title": "Сеть",
|
||||
"region_label": "Регион загрузки",
|
||||
"mirrors_title": "Свои зеркала (дополнительно)",
|
||||
"mirror_pypi": "URL индекса PyPI",
|
||||
"mirror_hf": "Endpoint Hugging Face",
|
||||
"mirror_python": "Зеркало загрузок Python",
|
||||
"insufficient_space": "Недостаточно места: для этой схемы нужно ~{{need}} на одном диске, доступно только {{free}}. Выберите другое расположение.",
|
||||
"blocked_not_writable": "Выбранная папка недоступна для записи — выберите другое расположение.",
|
||||
"total_required": "Всего потребуется: ~{{size}} (однократная загрузка при первом запуске)",
|
||||
"start": "Начать установку",
|
||||
"starting": "Запуск…",
|
||||
"compute_detected": "Обнаружено",
|
||||
"compute_match": "соответствует этой машине",
|
||||
"compute_auto_desc": "Выбирает лучший бэкенд этой машины во время запуска — CUDA на NVIDIA, MPS на Apple Silicon, иначе CPU.",
|
||||
"compute_rocm_desc": "Устанавливает ROCm-сборки PyTorch для видеокарт AMD в Linux. Если не уверены — оставьте «Авто».",
|
||||
"channel_stable_desc": "Только проверенные релизы — обновления приходят после проверки сообществом.",
|
||||
"channel_preview_desc": "Скользящие сборки из последнего main — новые движки и исправления раньше всех, изредка с шероховатостями.",
|
||||
"installing_title": "Установка",
|
||||
"activity_title": "Журнал",
|
||||
"stage_setup": "Настройка",
|
||||
"stage_models": "Модели и движки",
|
||||
"chip_required": "обязательно",
|
||||
"chip_optional": "опционально",
|
||||
"chip_engine": "движок",
|
||||
"lib_download": "Скачать",
|
||||
"lib_downloading": "загрузка…",
|
||||
"lib_use": "Использовать",
|
||||
"lib_active": "активен",
|
||||
"lib_in_settings": "установить позже в настройках",
|
||||
"lib_show_all": "Показать опциональные модели: {{count}}",
|
||||
"trust_line": "Всё работает и хранится на этой машине — без аккаунта, без облака, без телеметрии.",
|
||||
"resume_note": "Прерванные загрузки возобновляются автоматически — закрывать приложение безопасно.",
|
||||
"eta_left": "осталось ~{{eta}}",
|
||||
"first_sound_text": "Добро пожаловать в вашу студию. Каждое слово, которое вы слышите, сгенерировано на этой машине только что.",
|
||||
"first_sound_done": "Этот голос? Сгенерирован секунды назад, локально. Добро пожаловать."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -977,7 +977,7 @@
|
||||
"back": "Tillbaka till Studio",
|
||||
"badge": "Kommersiell licens",
|
||||
"hero_title": "Ship AI-röster i produktion",
|
||||
"hero_desc": "OmniVoice Studio är källtillgänglig under Functional Source License (FSL). De flesta användare kan utvärdera, prototyper och till och med distribuera internt utan ett kommersiellt avtal. Du behöver endast en kommersiell licens om du bygger en konkurrerande produkt eller tjänst, eller om ditt användningsfall faller utanför FSL:s gränser.",
|
||||
"hero_desc": "OmniVoice Studio är fri programvara med öppen källkod under GNU Affero General Public License v3 (AGPL-3.0) — gratis att använda, även för kommersiellt och internt företagsbruk. En kommersiell licens behövs bara om du vill bädda in OmniVoice Studio i en proprietär produkt eller tjänst med stängd källkod utan AGPL-3.0:s copyleft-skyldigheter.",
|
||||
"why_title": "Varför företag väljer OmniVoice",
|
||||
"pricing_title": "Prissättning",
|
||||
"faq_title": "Vanliga frågor",
|
||||
@@ -998,7 +998,7 @@
|
||||
"benefit_source_desc": "Full insyn i stapeln. Granska, fördela och anpassa inom licensvillkoren.",
|
||||
"benefit_lang": "646 språk",
|
||||
"benefit_lang_desc": "Transkribera, översätt och dubba på 646 språk med kvalitet på mänsklig nivå.",
|
||||
"hero_note": "Intern användning – även i stor skala – är gratis enligt FSL; en kommersiell licens krävs endast för att erbjuda OmniVoice till andra som en konkurrerande produkt eller tjänst (ett värd- eller pay-per-use API, en vidaresåld eller vitmärkt app). Prisnivåer kommer snart - hör av dig under tiden."
|
||||
"hero_note": "Användning, självhosting och kommersiellt bruk är gratis under AGPL-3.0 — även i stor skala. AGPL är en nätverks-copyleft-licens: om du modifierar OmniVoice och erbjuder den modifierade versionen till andra över ett nätverk måste du dela din modifierade källkod på samma villkor. En kommersiell licens lyfter dessa copyleft-skyldigheter för proprietära driftsättningar med stängd källkod. Prisplaner kommer snart — hör av dig under tiden."
|
||||
},
|
||||
"exportModal": {
|
||||
"export": "Exportera",
|
||||
@@ -1097,7 +1097,8 @@
|
||||
"script_french": "Icke-engelska (franska)",
|
||||
"aria_pause": "Pausa {{label}}",
|
||||
"aria_hear": "Hör {{label}}",
|
||||
"aria_replay": "Spela om {{label}} genom transcriber"
|
||||
"aria_replay": "Spela om {{label}} genom transcriber",
|
||||
"dictation_lede_hotkey_only": "Håll ner kortkommandot ovan var som helst på skrivbordet, tala, släpp — texten hamnar i appen med fokus. Tryck nu för att verifiera."
|
||||
},
|
||||
"direction": {
|
||||
"title": "Riktning för segment #{{id}}",
|
||||
@@ -1411,13 +1412,11 @@
|
||||
},
|
||||
"enterprise_faq": {
|
||||
"q_internal_tools": "Behöver jag en licens för interna verktyg?",
|
||||
"a_internal_tools": "Intern användning av dina anställda och entreprenörer är ett tillåtet syfte enligt FSL - ingen licens krävs. En kommersiell licens behövs när du gör OmniVoice tillgänglig för andra som en del av en konkurrerande produkt eller tjänst (återförsäljning, värd SaaS, white-label).",
|
||||
"a_internal_tools": "Nej. Användning av dina anställda och konsulter — inklusive intern modifiering och självhosting — är gratis under AGPL-3.0. En kommersiell licens behövs bara om du bäddar in OmniVoice i en proprietär produkt eller tjänst med stängd källkod och inte vill följa AGPL:s krav på källkodsdelning.",
|
||||
"q_try_before": "Kan jag prova innan jag binder mig?",
|
||||
"a_try_before": "Ja. Den fullständiga appen är gratis att ladda ner och köra lokalt för utvärdering under FSL. När du är redo att diskutera en kommersiell implementering, maila oss så går vi igenom detaljerna tillsammans.",
|
||||
"a_try_before": "Ja. Hela appen är gratis att ladda ner, köra och självhosta under AGPL-3.0 — inget avtal krävs. När du vill diskutera en kommersiell licens (proprietär användning), mejla oss så går vi igenom detaljerna tillsammans.",
|
||||
"q_watermark": "Hur är det med vattenstämpeln?",
|
||||
"a_watermark": "Den osynliga AudioSeal-vattenstämpeln är inbäddad som standard. Kommersiella licenstagare kan inaktivera det i Inställningar → Sekretess. Gratis/personligt bruk inkluderar alltid vattenstämpeln.",
|
||||
"q_apache": "Blir källan någonsin Apache 2.0?",
|
||||
"a_apache": "Ja. Varje utgåva konverteras automatiskt till Apache-licensen, version 2.0 på tvåårsdagen av dess publicering. Det betyder att dagens version är Apache 2.0 om två år, ingen åtgärd krävs från oss – FSL garanterar det oåterkalleligt."
|
||||
"a_watermark": "Den osynliga AudioSeal-vattenstämpeln är inbäddad som standard för alla. Kommersiella licenstagare kan inaktivera den i Inställningar → Sekretess."
|
||||
},
|
||||
"gallery_extra": {
|
||||
"save_prompt": "Ange ett namn för denna röstprofil:",
|
||||
@@ -1597,5 +1596,73 @@
|
||||
"none": "Inga utgåvor hittades",
|
||||
"load_error": "Det gick inte att läsa in utgåvor (offline?)",
|
||||
"retry_load": "Försök igen"
|
||||
},
|
||||
"firstrun": {
|
||||
"loading": "Förbereder installationen…",
|
||||
"title": "Konfigurera OmniVoice Studio",
|
||||
"subtitle": "Inget är installerat ännu — granska var allt hamnar och starta sedan. Kan ändras senare i Inställningar.",
|
||||
"language": "Språk",
|
||||
"mode_title": "Installationsläge",
|
||||
"mode_installed": "Installerad",
|
||||
"mode_installed_desc": "Använder systemets standardmappar. Rekommenderas för de flesta.",
|
||||
"mode_portable": "Portabel",
|
||||
"mode_portable_desc": "Allt ligger i en mapp bredvid appen — flytta den som en enhet till en annan disk eller dator.",
|
||||
"mode_portable_unavailable": "Otillgängligt: mappen bredvid appen är inte skrivbar.",
|
||||
"storage_title": "Lagring",
|
||||
"portable_folder": "Portabel mapp",
|
||||
"portable_folder_desc": "Miljö, modeller och dina röstdata — en mapp, helt flyttbar.",
|
||||
"env_dir": "Appmiljö",
|
||||
"env_dir_desc": "Python-runtime och AI-bibliotek.",
|
||||
"data_dir": "Röstdata & projekt",
|
||||
"data_dir_desc": "Dina röster, dubbningar, utdata och projektdatabasen.",
|
||||
"models_dir": "Modellcache",
|
||||
"models_dir_desc": "Nedladdade AI-modeller — den största och mest flyttbara delen.",
|
||||
"needs": "kräver ~{{size}}",
|
||||
"free": "{{size}} ledigt",
|
||||
"checking": "kontrollerar…",
|
||||
"not_writable": "ej skrivbar",
|
||||
"change": "Ändra…",
|
||||
"compute_title": "Beräkning",
|
||||
"compute_label": "GPU / accelerator",
|
||||
"compute_auto": "Auto (NVIDIA CUDA / Apple MPS / CPU)",
|
||||
"compute_rocm": "AMD GPU (ROCm, Linux)",
|
||||
"channel_label": "Uppdateringskanal",
|
||||
"channel_stable": "Stabil",
|
||||
"channel_preview": "Förhandsvisning (senaste main)",
|
||||
"network_title": "Nätverk",
|
||||
"region_label": "Nedladdningsregion",
|
||||
"mirrors_title": "Egna speglar (avancerat)",
|
||||
"mirror_pypi": "PyPI-index-URL",
|
||||
"mirror_hf": "Hugging Face-endpoint",
|
||||
"mirror_python": "Python-nedladdningsspegel",
|
||||
"insufficient_space": "Otillräckligt utrymme: den här layouten kräver ~{{need}} på en disk, endast {{free}} ledigt. Välj en annan plats.",
|
||||
"blocked_not_writable": "En vald mapp är inte skrivbar — välj en annan plats.",
|
||||
"total_required": "Totalt diskbehov: ~{{size}} (engångsnedladdning vid första användning)",
|
||||
"start": "Starta installationen",
|
||||
"starting": "Startar…",
|
||||
"compute_detected": "Identifierad",
|
||||
"compute_match": "matchar den här datorn",
|
||||
"compute_auto_desc": "Väljer den bästa backenden på den här datorn vid körning — CUDA på NVIDIA, MPS på Apple Silicon, annars CPU.",
|
||||
"compute_rocm_desc": "Installerar PyTorch ROCm-paket för AMD-grafikkort på Linux. Lämna på Auto om du är osäker.",
|
||||
"channel_stable_desc": "Endast testade utgåvor — uppdateringar kommer efter community-validering.",
|
||||
"channel_preview_desc": "Rullande byggen från senaste main — nya motorer och fixar först, ibland lite ojämnt.",
|
||||
"installing_title": "Installerar",
|
||||
"activity_title": "Aktivitet",
|
||||
"stage_setup": "Installation",
|
||||
"stage_models": "Modeller & motorer",
|
||||
"chip_required": "krävs",
|
||||
"chip_optional": "valfri",
|
||||
"chip_engine": "motor",
|
||||
"lib_download": "Ladda ner",
|
||||
"lib_downloading": "laddar ner…",
|
||||
"lib_use": "Använd",
|
||||
"lib_active": "aktiv",
|
||||
"lib_in_settings": "installera senare i Inställningar",
|
||||
"lib_show_all": "Visa {{count}} valfria modeller",
|
||||
"trust_line": "Allt körs och stannar på den här datorn — inget konto, inget moln, ingen telemetri.",
|
||||
"resume_note": "Avbrutna nedladdningar återupptas automatiskt — det är säkert att stänga appen.",
|
||||
"eta_left": "~{{eta}} kvar",
|
||||
"first_sound_text": "Välkommen till din studio. Varje ord du hör genererades på den här datorn, alldeles nyss.",
|
||||
"first_sound_done": "Den rösten? Genererad för några sekunder sedan, lokalt. Välkommen."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -977,7 +977,7 @@
|
||||
"back": "กลับไปที่สตูดิโอ",
|
||||
"badge": "ใบอนุญาตการค้า",
|
||||
"hero_title": "จัดส่งเสียง AI ในการผลิต",
|
||||
"hero_desc": "OmniVoice Studio มีแหล่งที่มาภายใต้ Functional Source License (FSL) ผู้ใช้ส่วนใหญ่สามารถประเมิน สร้างต้นแบบ และแม้แต่ปรับใช้ภายในโดยไม่ต้องมีข้อตกลงทางการค้า คุณต้องมีใบอนุญาตเชิงพาณิชย์เฉพาะในกรณีที่คุณกำลังสร้างผลิตภัณฑ์หรือบริการของคู่แข่ง หรือหากกรณีการใช้งานของคุณอยู่นอกขอบเขตของ FSL",
|
||||
"hero_desc": "OmniVoice Studio เป็นซอฟต์แวร์เสรีและโอเพนซอร์สภายใต้สัญญาอนุญาต GNU Affero General Public License v3 (AGPL-3.0) — ใช้งานได้ฟรี รวมถึงการใช้งานเชิงพาณิชย์และการใช้งานภายในองค์กร คุณต้องมีใบอนุญาตเชิงพาณิชย์เฉพาะเมื่อต้องการฝัง OmniVoice Studio ในผลิตภัณฑ์หรือบริการแบบปิดซอร์สหรือกรรมสิทธิ์ โดยไม่ปฏิบัติตามข้อผูกพัน copyleft ของ AGPL-3.0",
|
||||
"why_title": "เหตุใดธุรกิจจึงเลือก OmniVoice",
|
||||
"pricing_title": "ราคา",
|
||||
"faq_title": "คำถามทั่วไป",
|
||||
@@ -998,7 +998,7 @@
|
||||
"benefit_source_desc": "มองเห็นสแต็กได้เต็มรูปแบบ ตรวจสอบ แยก และปรับเปลี่ยนภายในข้อกำหนดสิทธิ์การใช้งาน",
|
||||
"benefit_lang": "646 ภาษา",
|
||||
"benefit_lang_desc": "ถอดเสียง แปล และพากย์ใน 646 ภาษาด้วยคุณภาพระดับมนุษย์",
|
||||
"hero_note": "การใช้งานภายใน — แม้ในปริมาณมาก — ไม่มีค่าใช้จ่ายภายใต้ FSL; จำเป็นต้องมีใบอนุญาตเชิงพาณิชย์เพื่อเสนอ OmniVoice ให้กับผู้อื่นในฐานะผลิตภัณฑ์หรือบริการของคู่แข่งเท่านั้น (API ที่โฮสต์หรือแบบจ่ายตามการใช้งาน แอปที่ขายต่อหรือมีป้ายกำกับสีขาว) ระดับราคากำลังจะมาในเร็วๆ นี้ โปรดติดต่อในระหว่างนี้"
|
||||
"hero_note": "การใช้งาน การโฮสต์ด้วยตนเอง และการใช้งานเชิงพาณิชย์ ทั้งหมดฟรีภายใต้ AGPL-3.0 — รวมถึงการใช้งานขนาดใหญ่ AGPL เป็นสัญญาอนุญาตแบบ copyleft ผ่านเครือข่าย: หากคุณแก้ไข OmniVoice และให้บริการเวอร์ชันที่แก้ไขนั้นแก่ผู้อื่นผ่านเครือข่าย คุณต้องเปิดเผยซอร์สโค้ดที่แก้ไขของคุณภายใต้เงื่อนไขเดียวกัน ใบอนุญาตเชิงพาณิชย์จะยกเว้นข้อผูกพัน copyleft เหล่านี้สำหรับการใช้งานแบบกรรมสิทธิ์/ปิดซอร์ส แผนราคาจะมาเร็ว ๆ นี้ — ระหว่างนี้ติดต่อเราได้เลย"
|
||||
},
|
||||
"exportModal": {
|
||||
"export": "ส่งออก",
|
||||
@@ -1097,7 +1097,8 @@
|
||||
"script_french": "ไม่ใช่ภาษาอังกฤษ (ฝรั่งเศส)",
|
||||
"aria_pause": "หยุดชั่วคราว {{label}}",
|
||||
"aria_hear": "ได้ยิน {{label}}",
|
||||
"aria_replay": "เล่นซ้ำ {{label}} ผ่านตัวถอดเสียง"
|
||||
"aria_replay": "เล่นซ้ำ {{label}} ผ่านตัวถอดเสียง",
|
||||
"dictation_lede_hotkey_only": "กดค้างปุ่มลัดด้านบนได้ทุกที่บนเดสก์ท็อป พูด แล้วปล่อย — ข้อความจะไปอยู่ในแอปที่โฟกัส กดตอนนี้เพื่อยืนยันว่าใช้งานได้"
|
||||
},
|
||||
"direction": {
|
||||
"title": "ทิศทางสำหรับส่วน #{{id}}",
|
||||
@@ -1411,13 +1412,11 @@
|
||||
},
|
||||
"enterprise_faq": {
|
||||
"q_internal_tools": "ฉันจำเป็นต้องมีใบอนุญาตสำหรับเครื่องมือภายในหรือไม่?",
|
||||
"a_internal_tools": "การใช้งานภายในโดยพนักงานและผู้รับเหมาของคุณเป็นวัตถุประสงค์ที่ได้รับอนุญาตภายใต้ FSL — ไม่ต้องมีใบอนุญาต จำเป็นต้องมีใบอนุญาตเชิงพาณิชย์เมื่อคุณทำให้ OmniVoice พร้อมใช้งานสำหรับผู้อื่นโดยเป็นส่วนหนึ่งของผลิตภัณฑ์หรือบริการของคู่แข่ง (การขายต่อ, SaaS ที่โฮสต์, white-label)",
|
||||
"a_internal_tools": "ไม่ต้อง การใช้งานโดยพนักงานและผู้รับเหมาของคุณ — รวมถึงการแก้ไขและโฮสต์เองภายในองค์กร — ฟรีภายใต้ AGPL-3.0 ใบอนุญาตเชิงพาณิชย์จำเป็นเฉพาะเมื่อคุณฝัง OmniVoice ในผลิตภัณฑ์หรือบริการแบบปิดซอร์สหรือกรรมสิทธิ์ และไม่ต้องการปฏิบัติตามข้อกำหนดการเปิดเผยซอร์สโค้ดของ AGPL",
|
||||
"q_try_before": "ฉันสามารถลองก่อนที่จะกระทำได้หรือไม่?",
|
||||
"a_try_before": "ใช่ แอปตัวเต็มสามารถดาวน์โหลดและเรียกใช้ภายในเครื่องได้ฟรีเพื่อการประเมินภายใต้ FSL เมื่อคุณพร้อมที่จะหารือเกี่ยวกับการปรับใช้งานเชิงพาณิชย์ โปรดส่งอีเมลถึงเรา แล้วเราจะดูรายละเอียดร่วมกัน",
|
||||
"a_try_before": "ได้ แอปฉบับเต็มดาวน์โหลด ใช้งาน และโฮสต์เองได้ฟรีภายใต้ AGPL-3.0 — ไม่ต้องมีข้อตกลงใด ๆ เมื่อคุณพร้อมจะหารือเรื่องใบอนุญาตเชิงพาณิชย์ (การใช้งานแบบกรรมสิทธิ์) ส่งอีเมลถึงเรา แล้วเราจะช่วยจัดการรายละเอียดร่วมกัน",
|
||||
"q_watermark": "แล้วลายน้ำล่ะ?",
|
||||
"a_watermark": "ลายน้ำ AudioSeal ที่มองไม่เห็นจะถูกฝังไว้ตามค่าเริ่มต้น ผู้ได้รับใบอนุญาตเชิงพาณิชย์สามารถปิดการใช้งานได้ในการตั้งค่า → ความเป็นส่วนตัว การใช้งานฟรี/ส่วนตัวจะมีลายน้ำรวมอยู่ด้วยเสมอ",
|
||||
"q_apache": "แหล่งที่มาเคยเป็น Apache 2.0 หรือไม่",
|
||||
"a_apache": "ใช่ แต่ละรุ่นจะแปลงเป็น Apache License เวอร์ชัน 2.0 โดยอัตโนมัติในวันครบรอบปีที่สองของการเผยแพร่ นั่นหมายถึงการเปิดตัวในวันนี้คือ Apache 2.0 ในอีกสองปี โดยไม่ต้องดำเนินการใดๆ จากเรา — FSL รับประกันว่าจะเพิกถอนไม่ได้"
|
||||
"a_watermark": "ลายน้ำ AudioSeal ที่มองไม่เห็นจะถูกฝังไว้ตามค่าเริ่มต้นสำหรับทุกคน ผู้ได้รับใบอนุญาตเชิงพาณิชย์สามารถปิดการใช้งานได้ในการตั้งค่า → ความเป็นส่วนตัว"
|
||||
},
|
||||
"gallery_extra": {
|
||||
"save_prompt": "ป้อนชื่อโปรไฟล์เสียงนี้:",
|
||||
@@ -1597,5 +1596,73 @@
|
||||
"none": "ไม่พบการเผยแพร่",
|
||||
"load_error": "ไม่สามารถโหลดรุ่นต่างๆ (ออฟไลน์?)",
|
||||
"retry_load": "ลองอีกครั้ง"
|
||||
},
|
||||
"firstrun": {
|
||||
"loading": "กำลังเตรียมการติดตั้ง…",
|
||||
"title": "ตั้งค่า OmniVoice Studio",
|
||||
"subtitle": "ยังไม่มีการติดตั้งใด ๆ — ตรวจสอบตำแหน่งจัดเก็บทั้งหมดก่อน แล้วจึงเริ่ม สามารถเปลี่ยนภายหลังได้ในการตั้งค่า",
|
||||
"language": "ภาษา",
|
||||
"mode_title": "โหมดการติดตั้ง",
|
||||
"mode_installed": "ติดตั้งปกติ",
|
||||
"mode_installed_desc": "ใช้โฟลเดอร์มาตรฐานของระบบ แนะนำสำหรับผู้ใช้ส่วนใหญ่",
|
||||
"mode_portable": "พกพา",
|
||||
"mode_portable_desc": "ทุกอย่างอยู่ในโฟลเดอร์เดียวข้างแอป — ย้ายไปยังดิสก์หรือเครื่องอื่นได้ทั้งชุด",
|
||||
"mode_portable_unavailable": "ใช้ไม่ได้: โฟลเดอร์ข้างแอปไม่สามารถเขียนได้",
|
||||
"storage_title": "พื้นที่จัดเก็บ",
|
||||
"portable_folder": "โฟลเดอร์พกพา",
|
||||
"portable_folder_desc": "สภาพแวดล้อม โมเดล และข้อมูลเสียงของคุณ — โฟลเดอร์เดียว ย้ายได้ทั้งหมด",
|
||||
"env_dir": "สภาพแวดล้อมของแอป",
|
||||
"env_dir_desc": "Python runtime และไลบรารี AI",
|
||||
"data_dir": "ข้อมูลเสียงและโปรเจ็กต์",
|
||||
"data_dir_desc": "เสียงของคุณ งานพากย์ ไฟล์ผลลัพธ์ และฐานข้อมูลโปรเจ็กต์",
|
||||
"models_dir": "แคชโมเดล",
|
||||
"models_dir_desc": "โมเดล AI ที่ดาวน์โหลดแล้ว — ส่วนที่ใหญ่ที่สุดและย้ายง่ายที่สุด",
|
||||
"needs": "ต้องการ ~{{size}}",
|
||||
"free": "ว่าง {{size}}",
|
||||
"checking": "กำลังตรวจสอบ…",
|
||||
"not_writable": "เขียนไม่ได้",
|
||||
"change": "เปลี่ยน…",
|
||||
"compute_title": "การประมวลผล",
|
||||
"compute_label": "GPU / ตัวเร่งความเร็ว",
|
||||
"compute_auto": "อัตโนมัติ (NVIDIA CUDA / Apple MPS / CPU)",
|
||||
"compute_rocm": "GPU AMD (ROCm, Linux)",
|
||||
"channel_label": "ช่องทางอัปเดต",
|
||||
"channel_stable": "เสถียร",
|
||||
"channel_preview": "พรีวิว (main ล่าสุด)",
|
||||
"network_title": "เครือข่าย",
|
||||
"region_label": "ภูมิภาคการดาวน์โหลด",
|
||||
"mirrors_title": "มิเรอร์กำหนดเอง (ขั้นสูง)",
|
||||
"mirror_pypi": "URL ดัชนี PyPI",
|
||||
"mirror_hf": "ปลายทาง Hugging Face",
|
||||
"mirror_python": "มิเรอร์ดาวน์โหลด Python",
|
||||
"insufficient_space": "พื้นที่ไม่พอ: รูปแบบนี้ต้องการ ~{{need}} บนดิสก์เดียว แต่เหลือเพียง {{free}} โปรดเลือกตำแหน่งอื่น",
|
||||
"blocked_not_writable": "โฟลเดอร์ที่เลือกเขียนไม่ได้ — โปรดเลือกตำแหน่งอื่น",
|
||||
"total_required": "พื้นที่ดิสก์ที่ต้องใช้ทั้งหมด: ~{{size}} (ดาวน์โหลดครั้งเดียวเมื่อใช้ครั้งแรก)",
|
||||
"start": "เริ่มการติดตั้ง",
|
||||
"starting": "กำลังเริ่ม…",
|
||||
"compute_detected": "ตรวจพบ",
|
||||
"compute_match": "ตรงกับเครื่องนี้",
|
||||
"compute_auto_desc": "เลือกแบ็กเอนด์ที่ดีที่สุดของเครื่องนี้ขณะทำงาน — CUDA บน NVIDIA, MPS บน Apple Silicon, นอกนั้นใช้ CPU",
|
||||
"compute_rocm_desc": "ติดตั้ง PyTorch ROCm สำหรับการ์ดจอ AMD บน Linux หากไม่แน่ใจให้คงไว้ที่อัตโนมัติ",
|
||||
"channel_stable_desc": "เฉพาะรุ่นที่ทดสอบแล้ว — อัปเดตมาถึงหลังการตรวจสอบจากชุมชน",
|
||||
"channel_preview_desc": "บิลด์ต่อเนื่องจาก main ล่าสุด — ได้เอนจินและการแก้ไขใหม่ก่อนใคร อาจมีจุดขรุขระบ้าง",
|
||||
"installing_title": "กำลังติดตั้ง",
|
||||
"activity_title": "กิจกรรม",
|
||||
"stage_setup": "ตั้งค่า",
|
||||
"stage_models": "โมเดลและเอนจิน",
|
||||
"chip_required": "จำเป็น",
|
||||
"chip_optional": "ตัวเลือก",
|
||||
"chip_engine": "เอนจิน",
|
||||
"lib_download": "ดาวน์โหลด",
|
||||
"lib_downloading": "กำลังดาวน์โหลด…",
|
||||
"lib_use": "ใช้",
|
||||
"lib_active": "ใช้งานอยู่",
|
||||
"lib_in_settings": "ติดตั้งภายหลังในการตั้งค่า",
|
||||
"lib_show_all": "แสดงโมเดลตัวเลือก {{count}} รายการ",
|
||||
"trust_line": "ทุกอย่างทำงานและอยู่บนเครื่องนี้ — ไม่มีบัญชี ไม่มีคลาวด์ ไม่มีเทเลเมทรี",
|
||||
"resume_note": "การดาวน์โหลดที่ขาดตอนจะทำต่ออัตโนมัติ — ปิดแอปได้อย่างปลอดภัย",
|
||||
"eta_left": "เหลืออีก ~{{eta}}",
|
||||
"first_sound_text": "ยินดีต้อนรับสู่สตูดิโอของคุณ ทุกคำที่คุณได้ยินถูกสร้างขึ้นบนเครื่องนี้เมื่อครู่นี้เอง",
|
||||
"first_sound_done": "เสียงเมื่อกี้? สร้างขึ้นเมื่อไม่กี่วินาทีก่อน บนเครื่องนี้ ยินดีต้อนรับ"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -977,7 +977,7 @@
|
||||
"back": "Studio'ya geri dön",
|
||||
"badge": "Ticari Lisans",
|
||||
"hero_title": "Üretimde yapay zeka seslerini gönderin",
|
||||
"hero_desc": "OmniVoice Studio, İşlevsel Kaynak Lisansı (FSL) kapsamında kaynak olarak mevcuttur. Çoğu kullanıcı ticari bir anlaşma olmadan dahili olarak değerlendirebilir, prototip oluşturabilir ve hatta dağıtabilir. Ticari lisansa yalnızca rakip bir ürün veya hizmet geliştiriyorsanız ya da kullanım alanınız FSL sınırlarının dışında kalıyorsa ihtiyacınız vardır.",
|
||||
"hero_desc": "OmniVoice Studio, GNU Affero Genel Kamu Lisansı v3 (AGPL-3.0) kapsamında özgür ve açık kaynaklı bir yazılımdır — ticari ve kurum içi iş kullanımı dahil, kullanımı ücretsizdir. Ticari lisans yalnızca OmniVoice Studio'yu AGPL-3.0'ın copyleft yükümlülükleri olmadan kapalı kaynaklı veya tescilli bir ürün ya da hizmete gömmek istediğinizde gereklidir.",
|
||||
"why_title": "İşletmeler neden OmniVoice'u seçiyor?",
|
||||
"pricing_title": "Fiyatlandırma",
|
||||
"faq_title": "Sık sorulan sorular",
|
||||
@@ -998,7 +998,7 @@
|
||||
"benefit_source_desc": "Yığına tam görünürlük. Lisans koşulları dahilinde denetleyin, çatallayın ve uyarlayın.",
|
||||
"benefit_lang": "646 dil",
|
||||
"benefit_lang_desc": "646 dilde insan düzeyinde kaliteyle metne dönüştürün, tercüme edin ve dublaj yapın.",
|
||||
"hero_note": "FSL kapsamında dahili kullanım - geniş ölçekte bile - ücretsizdir; ticari lisans yalnızca OmniVoice'u başkalarına rakip bir ürün veya hizmet (barındırılan veya kullanım başına ödemeli API, yeniden satılan veya beyaz etiketli bir uygulama) olarak sunmak için gereklidir. Fiyatlandırma katmanları yakında gelecek; bu arada bizimle iletişime geçin."
|
||||
"hero_note": "Kullanım, kendi sunucunuzda barındırma ve ticari kullanım AGPL-3.0 kapsamında tamamen ücretsizdir — büyük ölçekte bile. AGPL bir ağ copyleft lisansıdır: OmniVoice'u değiştirir ve bu değiştirilmiş sürümü ağ üzerinden başkalarına sunarsanız, değiştirilmiş kaynak kodunuzu aynı koşullarla paylaşmanız gerekir. Ticari lisans, tescilli ve kapalı kaynaklı dağıtımlar için bu copyleft yükümlülüklerini kaldırır. Fiyatlandırma planları yakında — bu arada bizimle iletişime geçin."
|
||||
},
|
||||
"exportModal": {
|
||||
"export": "İhracat",
|
||||
@@ -1097,7 +1097,8 @@
|
||||
"script_french": "İngilizce olmayan (Fransızca)",
|
||||
"aria_pause": "Duraklat {{label}}",
|
||||
"aria_hear": "{{label}} sesini duyun",
|
||||
"aria_replay": "{{label}} metnini aktarıcı aracılığıyla tekrar oynat"
|
||||
"aria_replay": "{{label}} metnini aktarıcı aracılığıyla tekrar oynat",
|
||||
"dictation_lede_hotkey_only": "Yukarıdaki kısayolu masaüstünde herhangi bir yerde basılı tutun, konuşun, bırakın — metin odaktaki uygulamaya düşer. Şimdi basıp doğrulayın."
|
||||
},
|
||||
"direction": {
|
||||
"title": "#{{id}} segmentinin yönü",
|
||||
@@ -1411,13 +1412,11 @@
|
||||
},
|
||||
"enterprise_faq": {
|
||||
"q_internal_tools": "Dahili araçlar için lisansa ihtiyacım var mı?",
|
||||
"a_internal_tools": "Çalışanlarınızın ve yüklenicilerinizin şirket içi kullanımı, FSL kapsamında İzin Verilen Amaçlardan biridir; lisans gerekmez. OmniVoice'u rakip bir ürün veya hizmetin (yeniden satış, barındırılan SaaS, beyaz etiket) parçası olarak başkalarının kullanımına sunduğunuzda ticari bir lisans gerekir.",
|
||||
"a_internal_tools": "Hayır. Çalışanlarınızın ve yüklenicilerinizin kullanımı — kurum içi değişiklik ve kendi sunucunuzda barındırma dahil — AGPL-3.0 kapsamında ücretsizdir. Ticari lisans yalnızca OmniVoice'u kapalı kaynaklı veya tescilli bir ürün ya da hizmete gömüp AGPL'nin kaynak paylaşım yükümlülüklerine uymak istemediğinizde gereklidir.",
|
||||
"q_try_before": "Taahhüt etmeden önce deneyebilir miyim?",
|
||||
"a_try_before": "Evet. Uygulamanın tamamını FSL kapsamında değerlendirme için yerel olarak indirmek ve çalıştırmak ücretsizdir. Ticari bir dağıtımı tartışmaya hazır olduğunuzda bize e-posta gönderin; ayrıntılar üzerinde birlikte çalışalım.",
|
||||
"a_try_before": "Evet. Uygulamanın tamamı AGPL-3.0 kapsamında ücretsiz indirilebilir, çalıştırılabilir ve kendi sunucunuzda barındırılabilir — hiçbir sözleşme gerekmez. Ticari (tescilli kullanım) lisansını görüşmeye hazır olduğunuzda bize e-posta gönderin; ayrıntıları birlikte netleştirelim.",
|
||||
"q_watermark": "Filigran ne olacak?",
|
||||
"a_watermark": "Görünmez AudioSeal filigranı varsayılan olarak gömülüdür. Ticari lisans sahipleri bunu Ayarlar → Gizlilik bölümünden devre dışı bırakabilir. Ücretsiz/kişisel kullanım her zaman filigranı da içerir.",
|
||||
"q_apache": "Kaynak hiç Apache 2.0 olur mu?",
|
||||
"a_apache": "Evet. Her sürüm, yayınlanmasının ikinci yıldönümünde otomatik olarak Apache Lisansı Sürüm 2.0'a dönüştürülür. Bu, bugünkü sürümün iki yıl içinde Apache 2.0 olacağı anlamına gelir; bizden herhangi bir işlem yapılmasına gerek yoktur - FSL bunu geri dönülmez bir şekilde garanti eder."
|
||||
"a_watermark": "Görünmez AudioSeal filigranı varsayılan olarak herkes için gömülüdür. Ticari lisans sahipleri bunu Ayarlar → Gizlilik bölümünden devre dışı bırakabilir."
|
||||
},
|
||||
"gallery_extra": {
|
||||
"save_prompt": "Bu ses profili için bir ad girin:",
|
||||
@@ -1597,5 +1596,73 @@
|
||||
"none": "Yayın bulunamadı",
|
||||
"load_error": "Sürümler yüklenemedi (çevrimdışı mı?)",
|
||||
"retry_load": "Yeniden dene"
|
||||
},
|
||||
"firstrun": {
|
||||
"loading": "Kurulum hazırlanıyor…",
|
||||
"title": "OmniVoice Studio'yu kur",
|
||||
"subtitle": "Henüz hiçbir şey kurulmadı — her şeyin nereye gideceğini gözden geçirin, sonra başlatın. Daha sonra Ayarlar'dan değiştirebilirsiniz.",
|
||||
"language": "Dil",
|
||||
"mode_title": "Kurulum modu",
|
||||
"mode_installed": "Kurulu",
|
||||
"mode_installed_desc": "Standart sistem klasörlerini kullanır. Çoğu kullanıcı için önerilir.",
|
||||
"mode_portable": "Taşınabilir",
|
||||
"mode_portable_desc": "Her şey uygulamanın yanındaki tek bir klasörde durur — başka bir diske veya makineye bütün halinde taşıyın.",
|
||||
"mode_portable_unavailable": "Kullanılamıyor: uygulamanın yanındaki klasör yazılabilir değil.",
|
||||
"storage_title": "Depolama",
|
||||
"portable_folder": "Taşınabilir klasör",
|
||||
"portable_folder_desc": "Çalışma ortamı, modeller ve ses verileriniz — tek klasör, tamamen taşınabilir.",
|
||||
"env_dir": "Uygulama ortamı",
|
||||
"env_dir_desc": "Python çalışma zamanı ve yapay zekâ kitaplıkları.",
|
||||
"data_dir": "Ses verileri ve projeler",
|
||||
"data_dir_desc": "Sesleriniz, dublajlar, çıktılar ve proje veritabanı.",
|
||||
"models_dir": "Model önbelleği",
|
||||
"models_dir_desc": "İndirilen yapay zekâ modelleri — en büyük ve en kolay taşınan kısım.",
|
||||
"needs": "~{{size}} gerekir",
|
||||
"free": "{{size}} boş",
|
||||
"checking": "denetleniyor…",
|
||||
"not_writable": "yazılamaz",
|
||||
"change": "Değiştir…",
|
||||
"compute_title": "Hesaplama",
|
||||
"compute_label": "GPU / hızlandırıcı",
|
||||
"compute_auto": "Otomatik (NVIDIA CUDA / Apple MPS / CPU)",
|
||||
"compute_rocm": "AMD GPU (ROCm, Linux)",
|
||||
"channel_label": "Güncelleme kanalı",
|
||||
"channel_stable": "Kararlı",
|
||||
"channel_preview": "Önizleme (en yeni main)",
|
||||
"network_title": "Ağ",
|
||||
"region_label": "İndirme bölgesi",
|
||||
"mirrors_title": "Özel yansılar (gelişmiş)",
|
||||
"mirror_pypi": "PyPI dizin URL'si",
|
||||
"mirror_hf": "Hugging Face uç noktası",
|
||||
"mirror_python": "Python indirme yansısı",
|
||||
"insufficient_space": "Yetersiz alan: bu düzen tek diskte ~{{need}} gerektirir, yalnızca {{free}} boş. Başka bir konum seçin.",
|
||||
"blocked_not_writable": "Seçilen bir klasör yazılabilir değil — başka bir konum seçin.",
|
||||
"total_required": "Gereken toplam disk: ~{{size}} (ilk kullanımda tek seferlik indirme)",
|
||||
"start": "Kurulumu başlat",
|
||||
"starting": "Başlatılıyor…",
|
||||
"compute_detected": "Algılandı",
|
||||
"compute_match": "bu makineyle eşleşiyor",
|
||||
"compute_auto_desc": "Çalışma anında bu makinenin en iyi arka ucunu seçer — NVIDIA'da CUDA, Apple Silicon'da MPS, aksi halde CPU.",
|
||||
"compute_rocm_desc": "Linux'ta AMD ekran kartları için PyTorch ROCm paketlerini kurar. Emin değilseniz Otomatik'te bırakın.",
|
||||
"channel_stable_desc": "Yalnızca test edilmiş sürümler — güncellemeler topluluk doğrulamasından sonra gelir.",
|
||||
"channel_preview_desc": "En yeni main'den sürekli derlemeler — yeni motorlar ve düzeltmeler önce gelir, ara sıra pürüz olabilir.",
|
||||
"installing_title": "Kuruluyor",
|
||||
"activity_title": "Etkinlik",
|
||||
"stage_setup": "Kurulum",
|
||||
"stage_models": "Modeller ve motorlar",
|
||||
"chip_required": "gerekli",
|
||||
"chip_optional": "isteğe bağlı",
|
||||
"chip_engine": "motor",
|
||||
"lib_download": "İndir",
|
||||
"lib_downloading": "indiriliyor…",
|
||||
"lib_use": "Kullan",
|
||||
"lib_active": "etkin",
|
||||
"lib_in_settings": "daha sonra Ayarlar’dan kur",
|
||||
"lib_show_all": "{{count}} isteğe bağlı modeli göster",
|
||||
"trust_line": "Her şey bu makinede çalışır ve kalır — hesap yok, bulut yok, telemetri yok.",
|
||||
"resume_note": "Yarıda kalan indirmeler otomatik devam eder — uygulamayı kapatmak güvenlidir.",
|
||||
"eta_left": "~{{eta}} kaldı",
|
||||
"first_sound_text": "Stüdyona hoş geldin. Duyduğun her kelime az önce bu makinede üretildi.",
|
||||
"first_sound_done": "O ses mi? Saniyeler önce, yerelde üretildi. Hoş geldin."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -977,7 +977,7 @@
|
||||
"back": "Назад до студії",
|
||||
"badge": "Комерційна ліцензія",
|
||||
"hero_title": "Надішліть голоси AI у виробництво",
|
||||
"hero_desc": "OmniVoice Studio доступний із джерелом відповідно до ліцензії на функціональне джерело (FSL). Більшість користувачів можуть оцінювати, прототипувати та навіть розгортати всередині країни без комерційної угоди. Вам потрібна комерційна ліцензія, лише якщо ви створюєте конкуруючий продукт чи послугу або якщо ваш варіант використання виходить за межі FSL.",
|
||||
"hero_desc": "OmniVoice Studio — це вільне програмне забезпечення з відкритим кодом за ліцензією GNU Affero General Public License v3 (AGPL-3.0): безкоштовне для будь-якого використання, зокрема комерційного та внутрішньокорпоративного. Комерційна ліцензія потрібна лише тоді, коли ви хочете вбудувати OmniVoice Studio в закритий або пропрієтарний продукт чи сервіс без копілефт-зобов'язань AGPL-3.0.",
|
||||
"why_title": "Чому компанії обирають OmniVoice",
|
||||
"pricing_title": "Ціноутворення",
|
||||
"faq_title": "Загальні запитання",
|
||||
@@ -998,7 +998,7 @@
|
||||
"benefit_source_desc": "Повна видимість стека. Аудит, розгалуження та адаптація в рамках умов ліцензії.",
|
||||
"benefit_lang": "646 мов",
|
||||
"benefit_lang_desc": "Транскрибуйте, перекладайте та дублюйте 646 мовами з якістю людського рівня.",
|
||||
"hero_note": "Внутрішнє використання — навіть у великих масштабах — є безкоштовним відповідно до FSL; комерційна ліцензія потрібна лише для того, щоб пропонувати OmniVoice іншим як конкуруючий продукт або послугу (розміщений API або API з оплатою за використання, програма, що перепродається або має мітку «білий»). Незабаром з’являться рівні цін — поки зв’яжіться з нами."
|
||||
"hero_note": "Використання, самостійний хостинг і комерційне застосування безкоштовні за AGPL-3.0 — зокрема й у великих масштабах. AGPL — це ліцензія з мережевим копілефтом: якщо ви модифікуєте OmniVoice і надаєте змінену версію іншим через мережу, ви зобов'язані відкрити свій змінений вихідний код на тих самих умовах. Комерційна ліцензія знімає ці копілефт-зобов'язання для пропрієтарних закритих розгортань. Тарифи з'являться незабаром — а поки що зв'яжіться з нами."
|
||||
},
|
||||
"exportModal": {
|
||||
"export": "Експорт",
|
||||
@@ -1097,7 +1097,8 @@
|
||||
"script_french": "не англійська (французька)",
|
||||
"aria_pause": "Пауза {{label}}",
|
||||
"aria_hear": "Слухати {{label}}",
|
||||
"aria_replay": "Повторити {{label}} через транскрибатор"
|
||||
"aria_replay": "Повторити {{label}} через транскрибатор",
|
||||
"dictation_lede_hotkey_only": "Утримуйте сполучення клавіш вище будь-де на робочому столі, говоріть і відпустіть — текст з'явиться в активному застосунку. Натисніть зараз, щоб перевірити."
|
||||
},
|
||||
"direction": {
|
||||
"title": "Напрямок для сегмента №{{id}}",
|
||||
@@ -1411,13 +1412,11 @@
|
||||
},
|
||||
"enterprise_faq": {
|
||||
"q_internal_tools": "Чи потрібна мені ліцензія на внутрішні інструменти?",
|
||||
"a_internal_tools": "Внутрішнє використання вашими співробітниками та підрядниками є дозволеною метою відповідно до FSL — ліцензія не потрібна. Комерційна ліцензія потрібна, коли ви робите OmniVoice доступним для інших як частину конкуруючого продукту чи послуги (перепродаж, хостинг SaaS, white-label).",
|
||||
"a_internal_tools": "Ні. Використання вашими працівниками та підрядниками — включно з внутрішніми модифікаціями й самостійним хостингом — безкоштовне за AGPL-3.0. Комерційна ліцензія потрібна лише якщо ви вбудовуєте OmniVoice у закритий або пропрієтарний продукт чи сервіс і не хочете виконувати вимоги AGPL щодо розкриття вихідного коду.",
|
||||
"q_try_before": "Чи можу я спробувати перед тим, як взяти участь?",
|
||||
"a_try_before": "так Повну програму можна безкоштовно завантажити та запустити локально для оцінки відповідно до FSL. Коли ви будете готові обговорити комерційне впровадження, напишіть нам електронною поштою, і ми разом обговоримо деталі.",
|
||||
"a_try_before": "Так. Повну версію застосунку можна безкоштовно завантажити, запускати та хостити самостійно за AGPL-3.0 — без жодної угоди. Коли будете готові обговорити комерційну ліцензію (пропрієтарне використання), напишіть нам — разом узгодимо деталі.",
|
||||
"q_watermark": "А як щодо водяного знака?",
|
||||
"a_watermark": "Невидимий водяний знак AudioSeal вбудовано за замовчуванням. Комерційні ліцензіати можуть вимкнути його в Налаштуваннях → Конфіденційність. Безкоштовне/особисте використання завжди містить водяний знак.",
|
||||
"q_apache": "Чи джерело коли-небудь стане Apache 2.0?",
|
||||
"a_apache": "так Кожен випуск автоматично перетворюється на ліцензію Apache версії 2.0 у другу річницю публікації. Це означає, що сьогоднішній випуск – це Apache 2.0 через два роки, від нас не вимагається жодних дій — FSL гарантує це безповоротно."
|
||||
"a_watermark": "Невидимий водяний знак AudioSeal вбудовано за замовчуванням для всіх. Комерційні ліцензіати можуть вимкнути його в Налаштуваннях → Конфіденційність."
|
||||
},
|
||||
"gallery_extra": {
|
||||
"save_prompt": "Введіть назву для цього голосового профілю:",
|
||||
@@ -1597,5 +1596,73 @@
|
||||
"none": "Випусків не знайдено",
|
||||
"load_error": "Не вдалося завантажити випуски (офлайн?)",
|
||||
"retry_load": "Повторіть спробу"
|
||||
},
|
||||
"firstrun": {
|
||||
"loading": "Підготовка налаштування…",
|
||||
"title": "Налаштування OmniVoice Studio",
|
||||
"subtitle": "Поки нічого не встановлено — перегляньте, куди все буде збережено, і починайте. Потім це можна змінити в Налаштуваннях.",
|
||||
"language": "Мова",
|
||||
"mode_title": "Режим встановлення",
|
||||
"mode_installed": "Встановлений",
|
||||
"mode_installed_desc": "Використовує стандартні системні теки. Рекомендовано більшості.",
|
||||
"mode_portable": "Портативний",
|
||||
"mode_portable_desc": "Усе зберігається в одній теці поряд із застосунком — переносьте її цілком на інший диск чи комп'ютер.",
|
||||
"mode_portable_unavailable": "Недоступно: тека поряд із застосунком недоступна для запису.",
|
||||
"storage_title": "Сховище",
|
||||
"portable_folder": "Портативна тека",
|
||||
"portable_folder_desc": "Середовище, моделі та ваші голосові дані — одна тека, повністю переносна.",
|
||||
"env_dir": "Середовище застосунку",
|
||||
"env_dir_desc": "Python та бібліотеки ШІ.",
|
||||
"data_dir": "Голосові дані та проєкти",
|
||||
"data_dir_desc": "Ваші голоси, дубляжі, результати та база даних проєктів.",
|
||||
"models_dir": "Кеш моделей",
|
||||
"models_dir_desc": "Завантажені моделі ШІ — найбільша та найлегша до переносу частина.",
|
||||
"needs": "потрібно ~{{size}}",
|
||||
"free": "вільно {{size}}",
|
||||
"checking": "перевірка…",
|
||||
"not_writable": "запис неможливий",
|
||||
"change": "Змінити…",
|
||||
"compute_title": "Обчислення",
|
||||
"compute_label": "GPU / прискорювач",
|
||||
"compute_auto": "Авто (NVIDIA CUDA / Apple MPS / CPU)",
|
||||
"compute_rocm": "GPU AMD (ROCm, Linux)",
|
||||
"channel_label": "Канал оновлень",
|
||||
"channel_stable": "Стабільний",
|
||||
"channel_preview": "Попередній (останній main)",
|
||||
"network_title": "Мережа",
|
||||
"region_label": "Регіон завантаження",
|
||||
"mirrors_title": "Власні дзеркала (додатково)",
|
||||
"mirror_pypi": "URL індексу PyPI",
|
||||
"mirror_hf": "Endpoint Hugging Face",
|
||||
"mirror_python": "Дзеркало завантажень Python",
|
||||
"insufficient_space": "Недостатньо місця: ця схема потребує ~{{need}} на одному диску, доступно лише {{free}}. Виберіть інше розташування.",
|
||||
"blocked_not_writable": "Вибрана тека недоступна для запису — виберіть інше розташування.",
|
||||
"total_required": "Загалом потрібно: ~{{size}} (одноразове завантаження під час першого використання)",
|
||||
"start": "Почати встановлення",
|
||||
"starting": "Запуск…",
|
||||
"compute_detected": "Виявлено",
|
||||
"compute_match": "відповідає цій машині",
|
||||
"compute_auto_desc": "Обирає найкращий бекенд цієї машини під час запуску — CUDA на NVIDIA, MPS на Apple Silicon, інакше CPU.",
|
||||
"compute_rocm_desc": "Встановлює ROCm-збірки PyTorch для відеокарт AMD у Linux. Якщо не впевнені — залиште «Авто».",
|
||||
"channel_stable_desc": "Лише перевірені релізи — оновлення надходять після перевірки спільнотою.",
|
||||
"channel_preview_desc": "Ковзні збірки з останнього main — нові рушії та виправлення першими, зрідка з шорсткостями.",
|
||||
"installing_title": "Встановлення",
|
||||
"activity_title": "Журнал",
|
||||
"stage_setup": "Налаштування",
|
||||
"stage_models": "Моделі та рушії",
|
||||
"chip_required": "обов’язково",
|
||||
"chip_optional": "опційно",
|
||||
"chip_engine": "рушій",
|
||||
"lib_download": "Завантажити",
|
||||
"lib_downloading": "завантаження…",
|
||||
"lib_use": "Використати",
|
||||
"lib_active": "активний",
|
||||
"lib_in_settings": "встановити пізніше в налаштуваннях",
|
||||
"lib_show_all": "Показати опційні моделі: {{count}}",
|
||||
"trust_line": "Усе працює й зберігається на цій машині — без облікового запису, хмари й телеметрії.",
|
||||
"resume_note": "Перервані завантаження відновлюються автоматично — закривати застосунок безпечно.",
|
||||
"eta_left": "залишилось ~{{eta}}",
|
||||
"first_sound_text": "Вітаємо у вашій студії. Кожне слово, яке ви чуєте, щойно згенеровано на цій машині.",
|
||||
"first_sound_done": "Той голос? Згенерований секунди тому, локально. Вітаємо."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -977,7 +977,7 @@
|
||||
"back": "Quay lại Studio",
|
||||
"badge": "Giấy phép thương mại",
|
||||
"hero_title": "Vận chuyển giọng nói AI trong quá trình sản xuất",
|
||||
"hero_desc": "OmniVoice Studio có sẵn nguồn theo Giấy phép Nguồn Chức năng (FSL). Hầu hết người dùng có thể đánh giá, tạo nguyên mẫu và thậm chí triển khai nội bộ mà không cần thỏa thuận thương mại. Bạn chỉ cần giấy phép thương mại nếu bạn đang xây dựng một sản phẩm hoặc dịch vụ cạnh tranh hoặc nếu trường hợp sử dụng của bạn nằm ngoài ranh giới của FSL.",
|
||||
"hero_desc": "OmniVoice Studio là phần mềm tự do, mã nguồn mở theo giấy phép GNU Affero General Public License v3 (AGPL-3.0) — miễn phí sử dụng, bao gồm cả mục đích thương mại và sử dụng nội bộ doanh nghiệp. Bạn chỉ cần giấy phép thương mại nếu muốn nhúng OmniVoice Studio vào sản phẩm hoặc dịch vụ mã nguồn đóng hay độc quyền mà không chịu các nghĩa vụ copyleft của AGPL-3.0.",
|
||||
"why_title": "Tại sao doanh nghiệp chọn OmniVoice",
|
||||
"pricing_title": "Định giá",
|
||||
"faq_title": "Câu hỏi thường gặp",
|
||||
@@ -998,7 +998,7 @@
|
||||
"benefit_source_desc": "Hiển thị đầy đủ vào ngăn xếp. Kiểm tra, phân nhánh và điều chỉnh theo các điều khoản cấp phép.",
|
||||
"benefit_lang": "646 ngôn ngữ",
|
||||
"benefit_lang_desc": "Phiên âm, dịch và lồng tiếng trên 646 ngôn ngữ với chất lượng ngang tầm con người.",
|
||||
"hero_note": "Việc sử dụng nội bộ — ngay cả ở quy mô lớn — đều miễn phí theo FSL; giấy phép thương mại chỉ được yêu cầu để cung cấp OmniVoice cho người khác dưới dạng sản phẩm hoặc dịch vụ cạnh tranh (API được lưu trữ hoặc trả tiền cho mỗi lần sử dụng, ứng dụng được bán lại hoặc được gắn nhãn trắng). Sắp có mức giá - hãy liên hệ trong thời gian chờ đợi."
|
||||
"hero_note": "Sử dụng, tự lưu trữ và dùng cho mục đích thương mại đều miễn phí theo AGPL-3.0 — kể cả ở quy mô lớn. AGPL là giấy phép copyleft qua mạng: nếu bạn chỉnh sửa OmniVoice và cung cấp phiên bản chỉnh sửa đó cho người khác qua mạng, bạn phải chia sẻ mã nguồn đã chỉnh sửa theo cùng điều khoản. Giấy phép thương mại sẽ gỡ bỏ các nghĩa vụ copyleft này cho các triển khai độc quyền, mã nguồn đóng. Các gói giá sẽ sớm ra mắt — trong lúc đó hãy liên hệ với chúng tôi."
|
||||
},
|
||||
"exportModal": {
|
||||
"export": "Xuất khẩu",
|
||||
@@ -1097,7 +1097,8 @@
|
||||
"script_french": "Không phải tiếng Anh (tiếng Pháp)",
|
||||
"aria_pause": "Tạm dừng {{label}}",
|
||||
"aria_hear": "Nghe _V_0__",
|
||||
"aria_replay": "Phát lại {{label}} thông qua người ghi âm"
|
||||
"aria_replay": "Phát lại {{label}} thông qua người ghi âm",
|
||||
"dictation_lede_hotkey_only": "Giữ phím tắt ở trên tại bất kỳ đâu trên màn hình, nói rồi thả — văn bản sẽ vào ứng dụng đang được chọn. Nhấn ngay để kiểm tra."
|
||||
},
|
||||
"direction": {
|
||||
"title": "Hướng cho đoạn #{{id}}",
|
||||
@@ -1411,13 +1412,11 @@
|
||||
},
|
||||
"enterprise_faq": {
|
||||
"q_internal_tools": "Tôi có cần giấy phép cho các công cụ nội bộ không?",
|
||||
"a_internal_tools": "Việc sử dụng nội bộ của nhân viên và nhà thầu của bạn là Mục đích được phép theo FSL — không cần giấy phép. Cần có giấy phép thương mại khi bạn cung cấp OmniVoice cho người khác như một phần của sản phẩm hoặc dịch vụ cạnh tranh (bán lại, SaaS được lưu trữ, nhãn trắng).",
|
||||
"a_internal_tools": "Không. Việc sử dụng bởi nhân viên và nhà thầu của bạn — bao gồm chỉnh sửa và tự lưu trữ nội bộ — là miễn phí theo AGPL-3.0. Giấy phép thương mại chỉ cần thiết nếu bạn nhúng OmniVoice vào sản phẩm hoặc dịch vụ mã nguồn đóng hay độc quyền và không muốn tuân thủ nghĩa vụ chia sẻ mã nguồn của AGPL.",
|
||||
"q_try_before": "Tôi có thể thử trước khi cam kết không?",
|
||||
"a_try_before": "Vâng. Ứng dụng đầy đủ được tải xuống và chạy cục bộ miễn phí để đánh giá theo FSL. Khi bạn sẵn sàng thảo luận về việc triển khai thương mại, hãy gửi email cho chúng tôi và chúng ta sẽ cùng nhau giải quyết các chi tiết.",
|
||||
"a_try_before": "Có. Toàn bộ ứng dụng có thể tải xuống, chạy và tự lưu trữ miễn phí theo AGPL-3.0 — không cần bất kỳ thỏa thuận nào. Khi bạn sẵn sàng trao đổi về giấy phép thương mại (sử dụng độc quyền), hãy gửi email cho chúng tôi và chúng ta sẽ cùng thống nhất chi tiết.",
|
||||
"q_watermark": "Còn hình mờ thì sao?",
|
||||
"a_watermark": "Hình mờ AudioSeal vô hình được nhúng theo mặc định. Người được cấp phép thương mại có thể tắt nó trong Cài đặt → Quyền riêng tư. Sử dụng miễn phí/cá nhân luôn bao gồm hình mờ.",
|
||||
"q_apache": "Nguồn có bao giờ trở thành Apache 2.0 không?",
|
||||
"a_apache": "Vâng. Mỗi bản phát hành sẽ tự động chuyển đổi sang Giấy phép Apache, Phiên bản 2.0 vào ngày kỷ niệm thứ hai ngày xuất bản. Điều đó có nghĩa là bản phát hành hôm nay là Apache 2.0 sau hai năm nữa, chúng tôi không cần thực hiện hành động nào — FSL đảm bảo điều đó không thể hủy bỏ."
|
||||
"a_watermark": "Hình mờ AudioSeal vô hình được nhúng theo mặc định cho tất cả mọi người. Người được cấp phép thương mại có thể tắt nó trong Cài đặt → Quyền riêng tư."
|
||||
},
|
||||
"gallery_extra": {
|
||||
"save_prompt": "Nhập tên cho cấu hình giọng nói này:",
|
||||
@@ -1597,5 +1596,73 @@
|
||||
"none": "Không tìm thấy bản phát hành nào",
|
||||
"load_error": "Không thể tải bản phát hành (ngoại tuyến?)",
|
||||
"retry_load": "Thử lại"
|
||||
},
|
||||
"firstrun": {
|
||||
"loading": "Đang chuẩn bị thiết lập…",
|
||||
"title": "Thiết lập OmniVoice Studio",
|
||||
"subtitle": "Chưa có gì được cài đặt — hãy xem lại nơi lưu mọi thứ rồi bắt đầu. Có thể thay đổi sau trong Cài đặt.",
|
||||
"language": "Ngôn ngữ",
|
||||
"mode_title": "Chế độ cài đặt",
|
||||
"mode_installed": "Cài đặt",
|
||||
"mode_installed_desc": "Dùng các thư mục hệ thống tiêu chuẩn. Khuyến nghị cho đa số người dùng.",
|
||||
"mode_portable": "Di động",
|
||||
"mode_portable_desc": "Mọi thứ nằm trong một thư mục cạnh ứng dụng — di chuyển nguyên khối sang ổ đĩa hoặc máy khác.",
|
||||
"mode_portable_unavailable": "Không khả dụng: thư mục cạnh ứng dụng không ghi được.",
|
||||
"storage_title": "Lưu trữ",
|
||||
"portable_folder": "Thư mục di động",
|
||||
"portable_folder_desc": "Môi trường, mô hình và dữ liệu giọng nói của bạn — một thư mục, di chuyển trọn vẹn.",
|
||||
"env_dir": "Môi trường ứng dụng",
|
||||
"env_dir_desc": "Python runtime và các thư viện AI.",
|
||||
"data_dir": "Dữ liệu giọng nói & dự án",
|
||||
"data_dir_desc": "Giọng nói, lồng tiếng, tệp xuất và cơ sở dữ liệu dự án của bạn.",
|
||||
"models_dir": "Bộ nhớ đệm mô hình",
|
||||
"models_dir_desc": "Các mô hình AI đã tải — phần lớn nhất và dễ di dời nhất.",
|
||||
"needs": "cần ~{{size}}",
|
||||
"free": "còn trống {{size}}",
|
||||
"checking": "đang kiểm tra…",
|
||||
"not_writable": "không ghi được",
|
||||
"change": "Thay đổi…",
|
||||
"compute_title": "Tính toán",
|
||||
"compute_label": "GPU / bộ tăng tốc",
|
||||
"compute_auto": "Tự động (NVIDIA CUDA / Apple MPS / CPU)",
|
||||
"compute_rocm": "GPU AMD (ROCm, Linux)",
|
||||
"channel_label": "Kênh cập nhật",
|
||||
"channel_stable": "Ổn định",
|
||||
"channel_preview": "Xem trước (main mới nhất)",
|
||||
"network_title": "Mạng",
|
||||
"region_label": "Khu vực tải xuống",
|
||||
"mirrors_title": "Mirror tùy chỉnh (nâng cao)",
|
||||
"mirror_pypi": "URL chỉ mục PyPI",
|
||||
"mirror_hf": "Endpoint Hugging Face",
|
||||
"mirror_python": "Mirror tải Python",
|
||||
"insufficient_space": "Không đủ dung lượng: bố cục này cần ~{{need}} trên cùng một ổ đĩa, chỉ còn {{free}}. Hãy chọn vị trí khác.",
|
||||
"blocked_not_writable": "Một thư mục đã chọn không ghi được — hãy chọn vị trí khác.",
|
||||
"total_required": "Tổng dung lượng cần: ~{{size}} (tải một lần khi dùng lần đầu)",
|
||||
"start": "Bắt đầu cài đặt",
|
||||
"starting": "Đang khởi động…",
|
||||
"compute_detected": "Đã phát hiện",
|
||||
"compute_match": "khớp với máy này",
|
||||
"compute_auto_desc": "Chọn backend tốt nhất của máy này khi chạy — CUDA trên NVIDIA, MPS trên Apple Silicon, còn lại dùng CPU.",
|
||||
"compute_rocm_desc": "Cài wheel PyTorch ROCm cho card đồ họa AMD trên Linux. Nếu không chắc, hãy để Tự động.",
|
||||
"channel_stable_desc": "Chỉ các bản phát hành đã kiểm thử — cập nhật đến sau khi cộng đồng xác nhận.",
|
||||
"channel_preview_desc": "Bản dựng liên tục từ main mới nhất — engine và bản sửa mới nhất trước tiên, đôi khi còn thô.",
|
||||
"installing_title": "Đang cài đặt",
|
||||
"activity_title": "Hoạt động",
|
||||
"stage_setup": "Thiết lập",
|
||||
"stage_models": "Mô hình & engine",
|
||||
"chip_required": "bắt buộc",
|
||||
"chip_optional": "tùy chọn",
|
||||
"chip_engine": "engine",
|
||||
"lib_download": "Tải xuống",
|
||||
"lib_downloading": "đang tải…",
|
||||
"lib_use": "Dùng",
|
||||
"lib_active": "đang dùng",
|
||||
"lib_in_settings": "cài sau trong Cài đặt",
|
||||
"lib_show_all": "Hiện {{count}} mô hình tùy chọn",
|
||||
"trust_line": "Mọi thứ chạy và lưu trên máy này — không tài khoản, không đám mây, không telemetry.",
|
||||
"resume_note": "Tải xuống bị gián đoạn sẽ tự tiếp tục — đóng ứng dụng vẫn an toàn.",
|
||||
"eta_left": "còn ~{{eta}}",
|
||||
"first_sound_text": "Chào mừng đến với studio của bạn. Mỗi từ bạn đang nghe vừa được tạo trên chính máy này.",
|
||||
"first_sound_done": "Giọng nói đó? Vừa được tạo vài giây trước, ngay trên máy. Chào mừng."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -947,7 +947,7 @@
|
||||
"back": "返回工作室",
|
||||
"badge": "商业许可",
|
||||
"hero_title": "在生产环境中部署 AI 语音",
|
||||
"hero_desc": "OmniVoice Studio 采用功能性源代码许可(FSL)。大多数用户可以在无需商业协议的情况下评估、原型开发甚至内部部署。只有在构建竞争产品或服务,或您的使用场景超出 FSL 范围时,才需要商业许可。",
|
||||
"hero_desc": "OmniVoice Studio 是基于 GNU Affero 通用公共许可证第 3 版(AGPL-3.0)的自由开源软件——可免费使用,包括商业用途和企业内部用途。只有当您希望在不承担 AGPL-3.0 著佐权(copyleft)义务的情况下,将 OmniVoice Studio 嵌入闭源或专有产品/服务时,才需要商业许可证。",
|
||||
"why_title": "为什么企业选择 OmniVoice",
|
||||
"pricing_title": "定价",
|
||||
"faq_title": "常见问题",
|
||||
@@ -968,7 +968,7 @@
|
||||
"benefit_source_desc": "完全可见的技术栈。在许可条款内审计、分叉和适配。",
|
||||
"benefit_lang": "646 种语言",
|
||||
"benefit_lang_desc": "以人类级质量转录、翻译和配音 646 种语言。",
|
||||
"hero_note": "根据 FSL,内部使用(即使是大规模使用)也是免费的;仅需要商业许可证才能将 OmniVoice 作为竞争产品或服务(托管或按使用付费的 API、转售或白标应用程序)提供给其他人。定价等级即将推出 - 请同时联系我们。"
|
||||
"hero_note": "使用、自托管和商业用途在 AGPL-3.0 下均免费——包括大规模使用。AGPL 是网络著佐权许可证:如果您修改 OmniVoice 并通过网络向他人提供该修改版本,则必须以相同条款公开您修改后的源代码。商业许可证可为专有闭源部署免除这些著佐权义务。定价方案即将推出——在此期间欢迎与我们联系。"
|
||||
},
|
||||
"exportModal": {
|
||||
"export": "导出",
|
||||
@@ -1067,7 +1067,8 @@
|
||||
"script_french": "非英语(法语)",
|
||||
"aria_pause": "暂停{{label}}",
|
||||
"aria_hear": "听到{{label}}",
|
||||
"aria_replay": "通过转录器重播 {{label}}"
|
||||
"aria_replay": "通过转录器重播 {{label}}",
|
||||
"dictation_lede_hotkey_only": "在桌面任意位置按住上方快捷键说话,松开后文字会输入到当前聚焦的应用中。现在按一下即可验证是否生效。"
|
||||
},
|
||||
"bootstrap": {
|
||||
"title": "OmniVoice Studio",
|
||||
@@ -1411,13 +1412,11 @@
|
||||
},
|
||||
"enterprise_faq": {
|
||||
"q_internal_tools": "我需要内部工具的许可证吗?",
|
||||
"a_internal_tools": "您的员工和承包商的内部使用是 FSL 允许的目的 — 无需许可。当您将 OmniVoice 作为竞争产品或服务(转售、托管 SaaS、白标)的一部分提供给其他人时,需要商业许可证。",
|
||||
"a_internal_tools": "不需要。您的员工和承包商的使用——包括内部修改和自托管——在 AGPL-3.0 下是免费的。只有当您将 OmniVoice 嵌入闭源或专有产品/服务、且不愿遵守 AGPL 的源代码公开义务时,才需要商业许可证。",
|
||||
"q_try_before": "我可以在提交之前尝试一下吗?",
|
||||
"a_try_before": "是的。完整的应用程序可免费下载并在本地运行,以便在 FSL 下进行评估。当您准备好讨论商业部署时,请给我们发送电子邮件,我们将共同解决细节问题。",
|
||||
"a_try_before": "可以。完整应用可在 AGPL-3.0 下免费下载、运行和自托管——无需任何协议。当您准备讨论商业(专有用途)许可证时,请发邮件联系我们,我们将一起敲定细节。",
|
||||
"q_watermark": "水印呢?",
|
||||
"a_watermark": "默认情况下嵌入不可见的 AudioSeal 水印。商业许可持有者可以在“设置”→“隐私”中禁用它。免费/个人使用始终包含水印。",
|
||||
"q_apache": "源代码会变成 Apache 2.0 吗?",
|
||||
"a_apache": "是的。每个版本都会在其发布两周年时自动转换为 Apache 许可证 2.0 版。这意味着今天发布的版本是两年后的 Apache 2.0,我们不需要采取任何行动 - FSL 保证它不可撤销。"
|
||||
"a_watermark": "默认情况下为所有人嵌入不可见的 AudioSeal 水印。商业许可持有者可以在“设置”→“隐私”中禁用它。"
|
||||
},
|
||||
"gallery_extra": {
|
||||
"save_prompt": "输入此语音配置文件的名称:",
|
||||
@@ -1597,5 +1596,73 @@
|
||||
"none": "未找到任何版本",
|
||||
"load_error": "无法加载版本(离线?)",
|
||||
"retry_load": "重试"
|
||||
},
|
||||
"firstrun": {
|
||||
"loading": "正在准备安装向导…",
|
||||
"title": "设置 OmniVoice Studio",
|
||||
"subtitle": "尚未安装任何内容——先确认各项存储位置,再开始安装。之后可在设置中更改。",
|
||||
"language": "语言",
|
||||
"mode_title": "安装模式",
|
||||
"mode_installed": "标准安装",
|
||||
"mode_installed_desc": "使用系统标准文件夹。推荐大多数用户使用。",
|
||||
"mode_portable": "便携模式",
|
||||
"mode_portable_desc": "所有内容都放在应用旁的一个文件夹里——可整体移动到其他磁盘或电脑。",
|
||||
"mode_portable_unavailable": "不可用:应用所在文件夹不可写。",
|
||||
"storage_title": "存储",
|
||||
"portable_folder": "便携文件夹",
|
||||
"portable_folder_desc": "运行环境、模型和您的语音数据——一个文件夹,可整体移动。",
|
||||
"env_dir": "应用环境",
|
||||
"env_dir_desc": "Python 运行时与 AI 库。",
|
||||
"data_dir": "语音数据与项目",
|
||||
"data_dir_desc": "您的声音、配音、输出文件和项目数据库。",
|
||||
"models_dir": "模型缓存",
|
||||
"models_dir_desc": "已下载的 AI 模型——占用最大、最适合放到别的磁盘。",
|
||||
"needs": "约需 {{size}}",
|
||||
"free": "可用 {{size}}",
|
||||
"checking": "检查中…",
|
||||
"not_writable": "不可写",
|
||||
"change": "更改…",
|
||||
"compute_title": "计算",
|
||||
"compute_label": "GPU / 加速器",
|
||||
"compute_auto": "自动(NVIDIA CUDA / Apple MPS / CPU)",
|
||||
"compute_rocm": "AMD 显卡(ROCm,Linux)",
|
||||
"channel_label": "更新通道",
|
||||
"channel_stable": "稳定版",
|
||||
"channel_preview": "预览版(最新 main)",
|
||||
"network_title": "网络",
|
||||
"region_label": "下载区域",
|
||||
"mirrors_title": "自定义镜像(高级)",
|
||||
"mirror_pypi": "PyPI 索引地址",
|
||||
"mirror_hf": "Hugging Face 端点",
|
||||
"mirror_python": "Python 下载镜像",
|
||||
"insufficient_space": "磁盘空间不足:此方案需要同一磁盘约 {{need}},当前仅剩 {{free}}。请更换位置。",
|
||||
"blocked_not_writable": "所选文件夹不可写——请更换位置。",
|
||||
"total_required": "共需磁盘空间:约 {{size}}(首次使用时一次性下载)",
|
||||
"start": "开始安装",
|
||||
"starting": "正在启动…",
|
||||
"compute_detected": "已检测",
|
||||
"compute_match": "与本机匹配",
|
||||
"compute_auto_desc": "运行时自动选择本机最佳后端——NVIDIA 用 CUDA、Apple 芯片用 MPS,否则用 CPU。",
|
||||
"compute_rocm_desc": "为 Linux 上的 AMD 显卡安装 PyTorch ROCm 版本。不确定时请保持“自动”。",
|
||||
"channel_stable_desc": "仅推送经过测试的正式版本——经社区验证后才更新。",
|
||||
"channel_preview_desc": "来自最新 main 分支的滚动构建——最先获得新引擎和修复,偶有小问题。",
|
||||
"installing_title": "正在安装",
|
||||
"activity_title": "活动日志",
|
||||
"stage_setup": "设置",
|
||||
"stage_models": "模型与引擎",
|
||||
"chip_required": "必需",
|
||||
"chip_optional": "可选",
|
||||
"chip_engine": "引擎",
|
||||
"lib_download": "下载",
|
||||
"lib_downloading": "下载中…",
|
||||
"lib_use": "使用",
|
||||
"lib_active": "当前",
|
||||
"lib_in_settings": "稍后在设置中安装",
|
||||
"lib_show_all": "显示 {{count}} 个可选模型",
|
||||
"trust_line": "一切都在这台设备上运行和保存——无需账号、无云端、无遥测。",
|
||||
"resume_note": "下载中断后会自动续传——关闭应用也没关系。",
|
||||
"eta_left": "剩余约 {{eta}}",
|
||||
"first_sound_text": "欢迎来到你的工作室。你听到的每一个字,都是刚刚在这台设备上生成的。",
|
||||
"first_sound_done": "刚才的声音?几秒前在本地生成。欢迎入驻。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -977,7 +977,7 @@
|
||||
"back": "返回工作室",
|
||||
"badge": "商業許可",
|
||||
"hero_title": "在生產中傳送人工智慧聲音",
|
||||
"hero_desc": "OmniVoice Studio 可根據功能來源許可證 (FSL) 取得原始碼。大多數用戶可以在沒有商業協議的情況下進行評估、原型設計甚至內部部署。只有當您正在建立競爭產品或服務,或您的用例超出 FSL 範圍時,您才需要商業許可證。",
|
||||
"hero_desc": "OmniVoice Studio 是基於 GNU Affero 通用公眾授權條款第 3 版(AGPL-3.0)的自由開源軟體——可免費使用,包括商業用途與企業內部用途。只有當您想在不承擔 AGPL-3.0 著佐權(copyleft)義務的情況下,將 OmniVoice Studio 嵌入閉源或專有產品/服務時,才需要商業授權。",
|
||||
"why_title": "為什麼企業選擇 OmniVoice",
|
||||
"pricing_title": "定價",
|
||||
"faq_title": "常見問題",
|
||||
@@ -998,7 +998,7 @@
|
||||
"benefit_source_desc": "對堆疊的完全可見性。在許可條款範圍內進行審核、分叉和調整。",
|
||||
"benefit_lang": "646 種語言",
|
||||
"benefit_lang_desc": "以人類層次的品質轉錄、翻譯和配音 646 種語言。",
|
||||
"hero_note": "根據 FSL,內部使用(即使是大規模使用)也是免費的;僅需要商業許可證才能將 OmniVoice 作為競爭產品或服務(託管或按使用付費的 API、轉售或白標應用程式)提供給其他人。定價等級即將推出 - 請同時聯絡我們。"
|
||||
"hero_note": "使用、自行架設與商業用途在 AGPL-3.0 下皆免費——包括大規模使用。AGPL 是網路著佐權授權條款:如果您修改 OmniVoice 並透過網路向他人提供該修改版本,就必須以相同條款公開您修改後的原始碼。商業授權可為專有閉源部署免除這些著佐權義務。定價方案即將推出——在此期間歡迎與我們聯繫。"
|
||||
},
|
||||
"exportModal": {
|
||||
"export": "出口",
|
||||
@@ -1097,7 +1097,8 @@
|
||||
"script_french": "非英語(法語)",
|
||||
"aria_pause": "暫停{{label}}",
|
||||
"aria_hear": "聽到{{label}}",
|
||||
"aria_replay": "透過轉錄器重播 {{label}}"
|
||||
"aria_replay": "透過轉錄器重播 {{label}}",
|
||||
"dictation_lede_hotkey_only": "在桌面任意位置按住上方快速鍵說話,放開後文字會輸入到目前聚焦的應用程式。現在按一下即可驗證是否生效。"
|
||||
},
|
||||
"direction": {
|
||||
"title": "段 #{{id}} 的方向",
|
||||
@@ -1411,13 +1412,11 @@
|
||||
},
|
||||
"enterprise_faq": {
|
||||
"q_internal_tools": "我需要內部工具的授權嗎?",
|
||||
"a_internal_tools": "您的員工和承包商的內部使用是 FSL 允許的目的 — 無需許可。當您將 OmniVoice 作為競爭產品或服務(轉售、託管 SaaS、白標)的一部分提供給其他人時,需要商業許可證。",
|
||||
"a_internal_tools": "不需要。您的員工與承包商的使用——包括內部修改與自行架設——在 AGPL-3.0 下是免費的。只有當您將 OmniVoice 嵌入閉源或專有產品/服務,且不願遵守 AGPL 的原始碼公開義務時,才需要商業授權。",
|
||||
"q_try_before": "我可以在提交之前嘗試一下嗎?",
|
||||
"a_try_before": "是的。完整的應用程式可免費下載並在本地運行,以便在 FSL 下進行評估。當您準備好討論商業部署時,請給我們發送電子郵件,我們將共同解決細節問題。",
|
||||
"a_try_before": "可以。完整應用程式可在 AGPL-3.0 下免費下載、執行與自行架設——無需任何協議。當您準備討論商業(專有用途)授權時,請寄信給我們,我們會一起確認細節。",
|
||||
"q_watermark": "水印呢?",
|
||||
"a_watermark": "預設嵌入不可見的 AudioSeal 水印。商業許可持有者可以在「設定」→「隱私權」中停用它。免費/個人使用始終包含浮水印。",
|
||||
"q_apache": "原始碼會變成 Apache 2.0 嗎?",
|
||||
"a_apache": "是的。每個版本都會在其發布兩週年時自動轉換為 Apache 授權 2.0 版。這意味著今天發布的版本是兩年後的 Apache 2.0,我們不需要採取任何行動 - FSL 保證它不可撤銷。"
|
||||
"a_watermark": "預設為所有人嵌入不可見的 AudioSeal 浮水印。商業授權持有者可以在「設定」→「隱私權」中停用它。"
|
||||
},
|
||||
"gallery_extra": {
|
||||
"save_prompt": "輸入此語音設定檔的名稱:",
|
||||
@@ -1597,5 +1596,73 @@
|
||||
"none": "未找到任何版本",
|
||||
"load_error": "無法載入版本(離線?)",
|
||||
"retry_load": "重試"
|
||||
},
|
||||
"firstrun": {
|
||||
"loading": "正在準備安裝精靈…",
|
||||
"title": "設定 OmniVoice Studio",
|
||||
"subtitle": "尚未安裝任何內容——先確認各項儲存位置,再開始安裝。之後可在設定中變更。",
|
||||
"language": "語言",
|
||||
"mode_title": "安裝模式",
|
||||
"mode_installed": "標準安裝",
|
||||
"mode_installed_desc": "使用系統標準資料夾。建議大多數使用者採用。",
|
||||
"mode_portable": "可攜模式",
|
||||
"mode_portable_desc": "所有內容都放在應用程式旁的一個資料夾——可整體移動到其他磁碟或電腦。",
|
||||
"mode_portable_unavailable": "無法使用:應用程式旁的資料夾不可寫入。",
|
||||
"storage_title": "儲存",
|
||||
"portable_folder": "可攜資料夾",
|
||||
"portable_folder_desc": "執行環境、模型與您的語音資料——一個資料夾,可整體搬移。",
|
||||
"env_dir": "應用程式環境",
|
||||
"env_dir_desc": "Python 執行環境與 AI 函式庫。",
|
||||
"data_dir": "語音資料與專案",
|
||||
"data_dir_desc": "您的聲音、配音、輸出檔與專案資料庫。",
|
||||
"models_dir": "模型快取",
|
||||
"models_dir_desc": "已下載的 AI 模型——體積最大、最適合放到其他磁碟。",
|
||||
"needs": "約需 {{size}}",
|
||||
"free": "可用 {{size}}",
|
||||
"checking": "檢查中…",
|
||||
"not_writable": "不可寫入",
|
||||
"change": "變更…",
|
||||
"compute_title": "運算",
|
||||
"compute_label": "GPU / 加速器",
|
||||
"compute_auto": "自動(NVIDIA CUDA / Apple MPS / CPU)",
|
||||
"compute_rocm": "AMD 顯示卡(ROCm,Linux)",
|
||||
"channel_label": "更新頻道",
|
||||
"channel_stable": "穩定版",
|
||||
"channel_preview": "預覽版(最新 main)",
|
||||
"network_title": "網路",
|
||||
"region_label": "下載區域",
|
||||
"mirrors_title": "自訂鏡像(進階)",
|
||||
"mirror_pypi": "PyPI 索引網址",
|
||||
"mirror_hf": "Hugging Face 端點",
|
||||
"mirror_python": "Python 下載鏡像",
|
||||
"insufficient_space": "磁碟空間不足:此配置需要同一磁碟約 {{need}},目前僅剩 {{free}}。請更換位置。",
|
||||
"blocked_not_writable": "所選資料夾不可寫入——請更換位置。",
|
||||
"total_required": "共需磁碟空間:約 {{size}}(首次使用時一次性下載)",
|
||||
"start": "開始安裝",
|
||||
"starting": "正在啟動…",
|
||||
"compute_detected": "已偵測",
|
||||
"compute_match": "與本機相符",
|
||||
"compute_auto_desc": "執行時自動選擇本機最佳後端——NVIDIA 用 CUDA、Apple 晶片用 MPS,否則用 CPU。",
|
||||
"compute_rocm_desc": "為 Linux 上的 AMD 顯示卡安裝 PyTorch ROCm 版本。不確定時請保持「自動」。",
|
||||
"channel_stable_desc": "僅推送經過測試的正式版本——經社群驗證後才更新。",
|
||||
"channel_preview_desc": "來自最新 main 分支的滾動建置——最先獲得新引擎與修復,偶有小問題。",
|
||||
"installing_title": "正在安裝",
|
||||
"activity_title": "活動日誌",
|
||||
"stage_setup": "設定",
|
||||
"stage_models": "模型與引擎",
|
||||
"chip_required": "必需",
|
||||
"chip_optional": "可選",
|
||||
"chip_engine": "引擎",
|
||||
"lib_download": "下載",
|
||||
"lib_downloading": "下載中…",
|
||||
"lib_use": "使用",
|
||||
"lib_active": "目前",
|
||||
"lib_in_settings": "稍後在設定中安裝",
|
||||
"lib_show_all": "顯示 {{count}} 個可選模型",
|
||||
"trust_line": "一切都在這台裝置上執行與保存——無需帳號、無雲端、無遙測。",
|
||||
"resume_note": "下載中斷後會自動續傳——關閉應用程式也沒關係。",
|
||||
"eta_left": "剩餘約 {{eta}}",
|
||||
"first_sound_text": "歡迎來到你的工作室。你聽到的每一個字,都是剛剛在這台裝置上生成的。",
|
||||
"first_sound_done": "剛才的聲音?幾秒前在本機生成。歡迎加入。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,8 +15,12 @@ import './index.css';
|
||||
import App from './App.jsx';
|
||||
import RemoteAuthGate from './components/RemoteAuthGate';
|
||||
import { installConsoleCapture } from './utils/consoleBuffer.js';
|
||||
import { installGlobalErrorHandlers } from './utils/globalErrorHandlers.js';
|
||||
|
||||
installConsoleCapture();
|
||||
// After console capture so the underlying console.error of each uncaught
|
||||
// failure is already in the ring buffer when the toast appears.
|
||||
installGlobalErrorHandlers();
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { API } from '../api/client';
|
||||
import BatchAddDialog from '../components/BatchAddDialog';
|
||||
import toast from 'react-hot-toast';
|
||||
import { toastErrorWithReport } from '../utils/errorToast';
|
||||
import './BatchQueue.css';
|
||||
|
||||
/**
|
||||
@@ -78,7 +79,7 @@ export default function BatchQueue({ onBack }) {
|
||||
await enqueueBatchJob(file, langCodes, settings.voiceId || undefined, settings.preserveBg);
|
||||
success++;
|
||||
} catch (e) {
|
||||
toast.error(t('batch.enqueue_failed', { name: file.name, message: e.message }));
|
||||
toastErrorWithReport(t('batch.enqueue_failed', { name: file.name, message: e.message }), e);
|
||||
}
|
||||
}
|
||||
if (success > 0) {
|
||||
@@ -94,7 +95,7 @@ export default function BatchQueue({ onBack }) {
|
||||
toast.success(t('batch.job_cancelled'));
|
||||
reload();
|
||||
} catch (e) {
|
||||
toast.error(t('batch.cancel_failed', { message: e.message }));
|
||||
toastErrorWithReport(t('batch.cancel_failed', { message: e.message }), e);
|
||||
}
|
||||
}, [t, reload]);
|
||||
|
||||
@@ -104,7 +105,7 @@ export default function BatchQueue({ onBack }) {
|
||||
toast.success(t('batch.job_deleted'));
|
||||
reload();
|
||||
} catch (e) {
|
||||
toast.error(t('batch.delete_failed', { message: e.message }));
|
||||
toastErrorWithReport(t('batch.delete_failed', { message: e.message }), e);
|
||||
}
|
||||
}, [t, reload]);
|
||||
|
||||
|
||||
@@ -352,6 +352,8 @@
|
||||
|
||||
.dub-change-row { display: flex; gap: 8px; margin-top: 8px; align-items: center; }
|
||||
.dub-change-row__cta { flex: 1; margin-top: 0; }
|
||||
.dub-speakers-hint { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; color: var(--muted, #a89984); white-space: nowrap; }
|
||||
.dub-speakers-input { width: 52px; margin-left: 4px; padding: 4px 6px; border-radius: 6px; border: 1px solid var(--border, #3c3836); background: var(--input-bg, #282828); color: inherit; font-size: 12px; }
|
||||
|
||||
/* Compact disabled inputs in the idle skeleton */
|
||||
.input-base--xs { font-size: 0.65rem; }
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { Suspense, lazy, useState, useEffect, useCallback, useRef } from
|
||||
import { copyText } from "../utils/copyText";
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
PanelLeftOpen, PanelLeftClose, Film, Save, UploadCloud, Sparkles, Loader, Square,
|
||||
PanelLeftOpen, PanelLeftClose, Film, Save, UploadCloud, Sparkles, Loader, Square, Users,
|
||||
FileText, Play, DownloadIcon, Volume2, Link2,
|
||||
Languages, ChevronDown, ChevronUp, Wand2, Trash2, Check, Globe, UserSquare2, User, AlertCircle,
|
||||
ExternalLink, Copy,
|
||||
@@ -20,6 +20,7 @@ import { formatTime } from '../utils/format';
|
||||
import { API } from '../api/client';
|
||||
import { listTranslationEngines, installTranslationEngine } from '../api/engines';
|
||||
import toast from 'react-hot-toast';
|
||||
import { toastErrorWithReport } from '../utils/errorToast';
|
||||
import { Button, Segmented, Badge, Progress } from '../ui';
|
||||
import { openDocsFor, classifyError } from '../utils/errorDocsMap';
|
||||
import GlossaryPanel from '../components/GlossaryPanel';
|
||||
@@ -109,6 +110,8 @@ export default function DubTab(props) {
|
||||
const setDubLang = useAppStore(s => s.setDubLang);
|
||||
const dubLangCode = useAppStore(s => s.dubLangCode);
|
||||
const setDubLangCode = useAppStore(s => s.setDubLangCode);
|
||||
const dubNumSpeakers = useAppStore(s => s.dubNumSpeakers);
|
||||
const setDubNumSpeakers = useAppStore(s => s.setDubNumSpeakers);
|
||||
const dubInstruct = useAppStore(s => s.dubInstruct);
|
||||
const setDubInstruct = useAppStore(s => s.setDubInstruct);
|
||||
const dubTracks = useAppStore(s => s.dubTracks);
|
||||
@@ -211,7 +214,8 @@ export default function DubTab(props) {
|
||||
toast.success(t('dub.install_ok', { engine: engineId }), { id: progressToast });
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(t('dub.install_failed', { message: String(err.message || err).slice(0, 200) }), { id: progressToast, duration: 8000 });
|
||||
toast.dismiss(progressToast);
|
||||
toastErrorWithReport(t('dub.install_failed', { message: String(err.message || err).slice(0, 200) }), err);
|
||||
} finally {
|
||||
setEngineInstalling(null);
|
||||
}
|
||||
@@ -392,6 +396,23 @@ export default function DubTab(props) {
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<label className="dub-speakers-hint" title={t('dub.num_speakers_help')}>
|
||||
<Users size={13} /> {t('dub.num_speakers_label')}
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={20}
|
||||
step={1}
|
||||
className="dub-speakers-input"
|
||||
placeholder={t('dub.num_speakers_auto')}
|
||||
value={dubNumSpeakers ?? ''}
|
||||
disabled={dubStep === 'uploading' || dubStep === 'transcribing'}
|
||||
onChange={(e) => {
|
||||
const v = parseInt(e.target.value, 10);
|
||||
setDubNumSpeakers(Number.isFinite(v) && v > 0 ? Math.min(v, 20) : null);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<button className="btn-primary dub-change-row__cta"
|
||||
onClick={handleDubUpload}
|
||||
disabled={dubStep === 'uploading' || dubStep === 'transcribing'}>
|
||||
|
||||
@@ -14,10 +14,12 @@ import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import {
|
||||
Cpu, FileText, Info, ShieldCheck, RefreshCw, Trash2, ExternalLink,
|
||||
CheckCircle, AlertCircle, Plug, Download, Copy, Building2, KeyRound,
|
||||
Keyboard, Wifi, Palette,
|
||||
Keyboard, Wifi, Palette, Activity,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { openExternal } from '../api/external';
|
||||
import { API } from '../api/client';
|
||||
import { addBreadcrumb } from '../utils/breadcrumbs';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { systemLogs, systemLogsTauri, clearSystemLogs, clearTauriLogs } from '../api/system';
|
||||
import i18n, { LANGUAGES } from '../i18n';
|
||||
@@ -1010,6 +1012,7 @@ export function EnginesTab() {
|
||||
// its install / GPU / isolation state.
|
||||
const onSelect = useCallback(async (family, backendId) => {
|
||||
try {
|
||||
addBreadcrumb(`engine:${family}=${backendId}`);
|
||||
const r = await selectEngine(family, backendId);
|
||||
toast.success(t('settings.engine_switched', { family: family.toUpperCase(), engine: r.active }));
|
||||
} catch (e) {
|
||||
@@ -1096,6 +1099,46 @@ export default function Settings() {
|
||||
|
||||
// sysinfo polling is now handled by useSysinfo() hook above
|
||||
|
||||
// Self-check (/system/diagnose) — device, ffmpeg, HF token, disk, engines,
|
||||
// hub reachability. The report comes back pre-scrubbed (backend core/scrub)
|
||||
// so "Copy" output is safe to paste straight into a GitHub issue.
|
||||
const [selfCheck, setSelfCheck] = useState(null);
|
||||
const [selfCheckRunning, setSelfCheckRunning] = useState(false);
|
||||
const runSelfCheck = useCallback(async () => {
|
||||
setSelfCheckRunning(true);
|
||||
try {
|
||||
const r = await fetch(`${API}/system/diagnose`);
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
setSelfCheck(await r.json());
|
||||
} catch (e) {
|
||||
toast.error(t('about.self_check_failed', { message: e?.message || e }));
|
||||
} finally {
|
||||
setSelfCheckRunning(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
// Diagnostic bundle — zip of self-check + error journal + scrubbed log
|
||||
// tails, saved to the outputs dir and revealed so the user can drag it
|
||||
// onto a GitHub issue (logs never fit in the prefilled-URL report).
|
||||
const [bundleBuilding, setBundleBuilding] = useState(false);
|
||||
const saveDiagnosticBundle = useCallback(async () => {
|
||||
setBundleBuilding(true);
|
||||
try {
|
||||
const r = await fetch(`${API}/system/diagnostic-bundle`, { method: 'POST' });
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
const j = await r.json();
|
||||
toast.success(t('about.bundle_saved', { filename: j.filename }));
|
||||
try {
|
||||
const { exportReveal } = await import('../api/exports');
|
||||
await exportReveal({ path: j.path });
|
||||
} catch { /* reveal is best-effort — the toast already names the file */ }
|
||||
} catch (e) {
|
||||
toast.error(t('about.bundle_failed', { message: e?.message || e }));
|
||||
} finally {
|
||||
setBundleBuilding(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const copyDiagnostics = useCallback(async () => {
|
||||
const nav = typeof navigator !== 'undefined' ? navigator : {};
|
||||
const ua = nav.userAgent || '—';
|
||||
@@ -1355,7 +1398,7 @@ export default function Settings() {
|
||||
<Row label={t('about.version')} value={appVersion || info?.app_version || '—'} mono />
|
||||
<Row label={t('about.tauri_runtime')} value={tauriVersion || (isTauri() ? '—' : t('about.web_preview'))} mono />
|
||||
<Row label={t('about.platform')} value={info?.platform || '—'} />
|
||||
<Row label={t('about.architecture')} value={typeof navigator !== 'undefined' ? (navigator.userAgentData?.platform || navigator.platform || '—') : '—'} mono />
|
||||
<Row label={t('about.architecture')} value={info?.arch || '—'} mono />
|
||||
<Row label={t('about.python')} value={info?.python || '—'} mono />
|
||||
<Row label={t('about.compute_device')} value={info?.device || '—'} mono />
|
||||
<Row label={t('about.gpu_active')} value={hw?.gpu_active
|
||||
@@ -1414,6 +1457,24 @@ export default function Settings() {
|
||||
{updateState === 'downloading' ? t('about.downloading') : t('about.check_updates')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="md"
|
||||
leading={!selfCheckRunning && <Activity size={12} />}
|
||||
onClick={runSelfCheck}
|
||||
loading={selfCheckRunning}
|
||||
>
|
||||
{t('about.self_check')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="md"
|
||||
leading={!bundleBuilding && <Download size={12} />}
|
||||
onClick={saveDiagnosticBundle}
|
||||
loading={bundleBuilding}
|
||||
>
|
||||
{t('about.save_bundle')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="md"
|
||||
@@ -1447,6 +1508,32 @@ export default function Settings() {
|
||||
{t('about.commercial_license')}
|
||||
</Button>
|
||||
</div>
|
||||
{selfCheck && (
|
||||
<div className="settings-selfcheck">
|
||||
{selfCheck.checks.map((c) => (
|
||||
<Row
|
||||
key={c.id}
|
||||
label={c.label}
|
||||
value={
|
||||
<span>
|
||||
<Badge tone={c.status === 'ok' ? 'success' : c.status === 'warn' ? 'warn' : 'danger'}>
|
||||
{c.status === 'ok'
|
||||
? <CheckCircle size={11} />
|
||||
: <AlertCircle size={11} />} {t(`about.self_check_${c.status}`)}
|
||||
</Badge>
|
||||
{' '}{c.detail}
|
||||
{c.hint && <span className="settings-muted"> — {c.hint}</span>}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<p className="settings-muted">
|
||||
{selfCheck.summary.ok
|
||||
? t('about.self_check_healthy')
|
||||
: t('about.self_check_attention', { count: selfCheck.summary.failures })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,556 +1,133 @@
|
||||
/* ═══════════════════════════════════════════════════════════════════════
|
||||
SetupWizard — premium onboarding flow
|
||||
═══════════════════════════════════════════════════════════════════════ */
|
||||
/* Model wizard — final act of the first-run journey. All structural styling
|
||||
* comes from the shared studio-console system (frs-* in FirstRunSetup.css);
|
||||
* this file only carries the wizard-specific glue. */
|
||||
|
||||
.setup-wizard {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
padding: 0 32px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Slide transition for step content ──────────────────────────────── */
|
||||
@keyframes swiz-enter {
|
||||
from { opacity: 0; transform: translateX(16px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
@keyframes swiz-enter-back {
|
||||
from { opacity: 0; transform: translateX(-16px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
/* Slides stack the act's panel + nav with the deck's rhythm. */
|
||||
.swiz-slide {
|
||||
animation: swiz-enter 0.3s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
display: contents;
|
||||
}
|
||||
|
||||
/* ── Step pills — connected stepper bar ─────────────────────────────── */
|
||||
.setup-wizard__steps {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8px 12px 0;
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
position: relative;
|
||||
}
|
||||
.setup-wizard__step {
|
||||
padding: 6px 16px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.74rem;
|
||||
letter-spacing: 0.02em;
|
||||
line-height: 1.4;
|
||||
color: var(--color-fg-muted, #928374);
|
||||
background: rgba(255, 255, 255, 0.025);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
cursor: pointer;
|
||||
transition: all 0.25s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
position: relative;
|
||||
font-weight: 500;
|
||||
}
|
||||
.setup-wizard__step:hover:not(.setup-wizard__step--active) {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.setup-wizard__step--active {
|
||||
background: linear-gradient(135deg, rgba(211, 134, 155, 0.2), rgba(211, 134, 155, 0.1));
|
||||
border-color: rgba(211, 134, 155, 0.4);
|
||||
color: #f3a5b6;
|
||||
font-weight: 600;
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(211, 134, 155, 0.15),
|
||||
0 0 12px rgba(211, 134, 155, 0.1);
|
||||
}
|
||||
.setup-wizard__step--done {
|
||||
color: #8ec07c;
|
||||
border-color: rgba(142, 192, 124, 0.3);
|
||||
background: rgba(142, 192, 124, 0.06);
|
||||
}
|
||||
|
||||
/* Connector lines between pills */
|
||||
.setup-wizard__step-connector {
|
||||
width: 20px;
|
||||
height: 1px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.setup-wizard__step-connector--done {
|
||||
background: rgba(142, 192, 124, 0.3);
|
||||
}
|
||||
|
||||
/* ── Hero — horizontal single-line ───────────────────────────────────── */
|
||||
.setup-wizard__hero {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 6px 12px 4px;
|
||||
flex-shrink: 0;
|
||||
max-width: 760px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
.setup-wizard__logo {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
filter: drop-shadow(0 0 8px rgba(211, 134, 155, 0.3));
|
||||
}
|
||||
.setup-wizard__hero-text {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
flex-direction: column;
|
||||
gap: 1.1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.setup-wizard__hero h1 {
|
||||
|
||||
.swiz-note {
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
font-family: var(--font-display, var(--font-sans));
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.025em;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.setup-wizard__sub {
|
||||
margin: 0;
|
||||
color: var(--color-fg-muted);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* ── Embed panel — the ONLY scrollable region ────────────────────────── */
|
||||
.setup-wizard__embed {
|
||||
padding: 4px 0;
|
||||
margin-top: 4px;
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* ── Nav bar — pinned to bottom ──────────────────────────────────────── */
|
||||
.setup-wizard__nav {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
padding: 8px 0 6px;
|
||||
background: var(--color-bg, #1d2021);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
/* ── Card / checklist rows ───────────────────────────────────────────── */
|
||||
.setup-wizard__card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.setup-wizard__card--error {
|
||||
background: rgba(251, 73, 52, 0.06);
|
||||
border-color: rgba(251, 73, 52, 0.3);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
.setup-wizard__card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-fg-muted);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.setup-wizard__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.setup-wizard__row-body { flex: 1; display: flex; flex-direction: column; gap: 3px; min-width: 0; }
|
||||
.setup-wizard__row-title { font-weight: 600; font-size: 0.84rem; line-height: 1.4; }
|
||||
|
||||
.setup-wizard__muted {
|
||||
color: var(--color-fg-muted);
|
||||
font-size: 0.74rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.setup-wizard__muted code { color: var(--color-fg-subtle); }
|
||||
|
||||
.setup-wizard__warn { color: var(--color-warn, #fabd2f); font-weight: 600; }
|
||||
|
||||
/* ── Welcome step — glassmorphism cards with stagger ─────────────────── */
|
||||
.setup-wizard__welcome {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.setup-wizard__welcome-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
@keyframes swiz-card-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px) scale(0.98);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
.swiz-note--warn {
|
||||
opacity: 1;
|
||||
color: var(--chrome-severity-warn, #d79921);
|
||||
}
|
||||
|
||||
.swiz-welcome-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
transition: all 0.25s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
animation: swiz-card-in 0.4s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.swiz-welcome-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 3px;
|
||||
background: linear-gradient(to bottom, rgba(211, 134, 155, 0.5), rgba(211, 134, 155, 0.1));
|
||||
border-radius: 3px 0 0 3px;
|
||||
}
|
||||
.swiz-welcome-card:nth-child(1) { animation-delay: 0s; }
|
||||
.swiz-welcome-card:nth-child(2) { animation-delay: 0.08s; }
|
||||
.swiz-welcome-card:nth-child(3) { animation-delay: 0.16s; }
|
||||
|
||||
.swiz-welcome-card:hover {
|
||||
background: rgba(255, 255, 255, 0.045);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.swiz-welcome-card__icon {
|
||||
flex-shrink: 0;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
background: rgba(211, 134, 155, 0.1);
|
||||
color: #d3869b;
|
||||
}
|
||||
.swiz-welcome-card__body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.swiz-welcome-card__title {
|
||||
font-weight: 600;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.3;
|
||||
margin-bottom: 2px;
|
||||
display: block;
|
||||
}
|
||||
.swiz-welcome-card__desc {
|
||||
margin: 0;
|
||||
color: var(--color-fg-muted);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.swiz-welcome-note {
|
||||
margin: 0;
|
||||
color: var(--color-fg-subtle);
|
||||
font-size: 0.74rem;
|
||||
line-height: 1.5;
|
||||
text-align: center;
|
||||
opacity: 0;
|
||||
animation: swiz-card-in 0.4s cubic-bezier(0.22, 1, 0.36, 1) 0.28s both;
|
||||
}
|
||||
|
||||
/* ── Preflight checklist ─────────────────────────────────────────────── */
|
||||
.swiz-checklist {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.swiz-check-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.swiz-check-header__label {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-fg-muted);
|
||||
}
|
||||
|
||||
@keyframes swiz-check-in {
|
||||
from { opacity: 0; transform: translateX(-8px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
|
||||
.swiz-check-row {
|
||||
animation: swiz-check-in 0.25s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
.swiz-check-row:nth-child(2) { animation-delay: 0.04s; }
|
||||
.swiz-check-row:nth-child(3) { animation-delay: 0.08s; }
|
||||
.swiz-check-row:nth-child(4) { animation-delay: 0.12s; }
|
||||
.swiz-check-row:nth-child(5) { animation-delay: 0.16s; }
|
||||
.swiz-check-row:nth-child(6) { animation-delay: 0.20s; }
|
||||
|
||||
.swiz-check-icon {
|
||||
flex-shrink: 0;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.swiz-check-icon--pass {
|
||||
background: rgba(142, 192, 124, 0.12);
|
||||
color: #8ec07c;
|
||||
}
|
||||
.swiz-check-icon--warn {
|
||||
background: rgba(250, 189, 47, 0.12);
|
||||
color: #fabd2f;
|
||||
}
|
||||
.swiz-check-icon--fail {
|
||||
background: rgba(251, 73, 52, 0.12);
|
||||
color: #fb4934;
|
||||
}
|
||||
/* Recheck action lives inside the engraved panel title row. */
|
||||
.swiz-recheck { letter-spacing: 0; text-transform: none; }
|
||||
|
||||
.swiz-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
color: var(--color-fg-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.swiz-loading .spinner { animation: spin 1s linear infinite; }
|
||||
|
||||
/* ── Model list ──────────────────────────────────────────────────────── */
|
||||
.setup-wizard__models {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.setup-wizard__models li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.setup-wizard__models code { font-size: 0.68rem; }
|
||||
|
||||
.setup-wizard__aggregate {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
opacity: 0.65;
|
||||
padding: 0.4rem 0;
|
||||
}
|
||||
|
||||
.setup-wizard__files {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-height: 280px;
|
||||
/* Embedded Settings panels (Model Store / Engines / DictationDemo) manage
|
||||
* their own internals; cap their height so the act scrolls inside itself
|
||||
* rather than pushing the nav off-screen. */
|
||||
.frs-embed {
|
||||
max-height: min(58vh, 640px);
|
||||
overflow-y: auto;
|
||||
padding: 4px 2px 2px;
|
||||
}
|
||||
.setup-wizard__file {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 120px 44px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
.setup-wizard__file-name {
|
||||
font-family: var(--font-mono);
|
||||
color: var(--chrome-fg-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.setup-wizard__file-pct {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--color-fg-muted);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.setup-wizard__actions {
|
||||
/* ── Unified library list (models + engines, one grammar) ─────────────── */
|
||||
|
||||
.swiz-lib {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
padding-top: 8px;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
max-height: min(56vh, 620px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* ── Inline HF token ─────────────────────────────────────────────────── */
|
||||
.models-toolbar__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.models-toolbar__hf-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.68rem;
|
||||
color: #fe8019;
|
||||
background: rgba(254, 128, 25, 0.08);
|
||||
border: 1px solid rgba(254, 128, 25, 0.2);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.models-toolbar__hf-btn:hover {
|
||||
background: rgba(254, 128, 25, 0.15);
|
||||
border-color: rgba(254, 128, 25, 0.35);
|
||||
}
|
||||
.models-toolbar__hf-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.models-toolbar__hf-input {
|
||||
width: 150px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 5px;
|
||||
border: 1px solid rgba(254, 128, 25, 0.25);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--chrome-fg, #ebdbb2);
|
||||
font-size: 0.7rem;
|
||||
font-family: var(--font-mono);
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.models-toolbar__hf-input:focus {
|
||||
border-color: rgba(254, 128, 25, 0.5);
|
||||
}
|
||||
.models-toolbar__hf-input::placeholder {
|
||||
color: var(--chrome-fg-dim, #665c54);
|
||||
}
|
||||
.models-toolbar__hf-ok {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
font-size: 0.66rem;
|
||||
color: #8ec07c;
|
||||
padding: 2px 6px;
|
||||
border-radius: 999px;
|
||||
background: rgba(142, 192, 124, 0.08);
|
||||
border: 1px solid rgba(142, 192, 124, 0.2);
|
||||
}
|
||||
.models-toolbar__hf-link {
|
||||
font-size: 0.68rem;
|
||||
color: #83a598;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.models-toolbar__hf-link:hover {
|
||||
color: #b8bb26;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.swiz-lib__row { gap: 0.8rem; padding: 0.5rem 0.7rem; }
|
||||
|
||||
/* ── Footnote — polished ─────────────────────────────────────────────── */
|
||||
.setup-wizard__footnote {
|
||||
color: var(--color-fg-subtle, #665c54);
|
||||
font-size: 0.66rem;
|
||||
margin: 0;
|
||||
padding: 6px 0 8px;
|
||||
text-align: center;
|
||||
line-height: 1.5;
|
||||
.swiz-lib__led {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.7;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
background: color-mix(in srgb, var(--frs-ink) 14%, transparent);
|
||||
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
.setup-wizard__footnote code {
|
||||
font-size: 0.62rem;
|
||||
color: var(--chrome-fg-dim, #665c54);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
|
||||
.swiz-lib__led--ok {
|
||||
background: var(--frs-ok);
|
||||
box-shadow: 0 0 5px 1px color-mix(in srgb, var(--frs-ok) 50%, transparent);
|
||||
}
|
||||
.setup-wizard__footnote-link {
|
||||
color: var(--chrome-accent, #d3869b);
|
||||
|
||||
.swiz-lib__led--active {
|
||||
background: var(--frs-accent);
|
||||
box-shadow: 0 0 6px 1px color-mix(in srgb, var(--frs-accent) 70%, transparent);
|
||||
}
|
||||
|
||||
.swiz-lib__led--busy {
|
||||
background: var(--frs-accent);
|
||||
animation: frs-hw-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.swiz-lib__chip { margin-left: 0.5rem; }
|
||||
|
||||
.swiz-lib__chip--req {
|
||||
color: color-mix(in srgb, var(--frs-accent) 90%, var(--frs-ink));
|
||||
background: color-mix(in srgb, var(--frs-accent) 14%, transparent);
|
||||
}
|
||||
|
||||
.swiz-lib__chip--eng {
|
||||
color: color-mix(in srgb, var(--frs-ink) 75%, transparent);
|
||||
background: color-mix(in srgb, var(--frs-ink) 10%, transparent);
|
||||
}
|
||||
|
||||
.swiz-lib__chip--opt {
|
||||
color: color-mix(in srgb, var(--frs-ink) 60%, transparent);
|
||||
background: color-mix(in srgb, var(--frs-ink) 7%, transparent);
|
||||
}
|
||||
|
||||
.swiz-lib__state {
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 0.64rem;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
text-decoration: none;
|
||||
opacity: 0.8;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.setup-wizard__footnote-link:hover {
|
||||
opacity: 1;
|
||||
text-decoration: underline;
|
||||
opacity: 0.55;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Missing models indicator ────────────────────────────────────────── */
|
||||
.swiz-missing {
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
background: rgba(251, 73, 52, 0.06);
|
||||
border: 1px solid rgba(251, 73, 52, 0.15);
|
||||
font-size: 0.72rem;
|
||||
.swiz-lib__state--active { color: var(--frs-accent); opacity: 1; }
|
||||
.swiz-lib__state--busy { color: var(--frs-accent); opacity: 1; font-variant-numeric: tabular-nums; }
|
||||
|
||||
.swiz-lib__act { flex-shrink: 0; }
|
||||
|
||||
/* Slim inline download bar under the row label. */
|
||||
.swiz-lib__bar {
|
||||
display: block;
|
||||
height: 3px;
|
||||
margin-top: 0.3rem;
|
||||
border-radius: 2px;
|
||||
background: color-mix(in srgb, var(--frs-ink) 8%, transparent);
|
||||
overflow: hidden;
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
.swiz-status-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 20px;
|
||||
color: var(--color-fg-muted);
|
||||
font-size: 0.78rem;
|
||||
.swiz-lib__bar > span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
background: var(--frs-accent);
|
||||
transition: width 400ms ease;
|
||||
}
|
||||
.swiz-status-loading .spinner { animation: spin 1s linear infinite; }
|
||||
|
||||
/* ── Responsive ──────────────────────────────────────────────────────── */
|
||||
@media (max-width: 640px) {
|
||||
.setup-wizard {
|
||||
padding: 0 14px;
|
||||
}
|
||||
.setup-wizard__steps { gap: 3px; padding: 8px 8px 0; }
|
||||
.setup-wizard__step { padding: 4px 10px; font-size: 0.7rem; }
|
||||
.setup-wizard__step-connector { width: 10px; }
|
||||
.setup-wizard__hero { gap: 8px; padding: 6px 8px 4px; }
|
||||
.setup-wizard__hero h1 { font-size: 1rem; }
|
||||
.setup-wizard__sub { font-size: 0.72rem; }
|
||||
.swiz-welcome-card { padding: 10px 14px; gap: 10px; }
|
||||
.swiz-welcome-card__icon { width: 26px; height: 26px; }
|
||||
}
|
||||
.swiz-lib__sub { display: block; min-width: 0; }
|
||||
|
||||
.swiz-lib__more { align-self: flex-start; }
|
||||
|
||||
+209
-237
@@ -1,13 +1,10 @@
|
||||
import React, { useCallback, useEffect, useState, useRef } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
CheckCircle, Loader, ArrowRight, AlertTriangle, XCircle,
|
||||
RefreshCw, Monitor, Download, Cog, FolderOpen,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '../ui';
|
||||
import { Loader } from 'lucide-react';
|
||||
import { useSetupStatus, usePreflight } from '../api/hooks';
|
||||
import { ModelStoreTab, EnginesTab } from './Settings';
|
||||
import WizardLibrary from '../components/WizardLibrary';
|
||||
import DictationDemo from '../components/DictationDemo';
|
||||
import '../components/FirstRunSetup.css';
|
||||
import './SetupWizard.css';
|
||||
import '../components/Misc.css';
|
||||
|
||||
@@ -39,13 +36,30 @@ async function revealPath(path) {
|
||||
} catch { /* ignore — probably web preview */ }
|
||||
}
|
||||
|
||||
const CHECK_ICON = {
|
||||
pass: <CheckCircle size={13} />,
|
||||
warn: <AlertTriangle size={13} />,
|
||||
fail: <XCircle size={13} />,
|
||||
};
|
||||
/** Whisper waveform — the journey's signature, same as setup + install. */
|
||||
function Waveform({ bars = 96 }) {
|
||||
const heights = useMemo(
|
||||
() => Array.from({ length: bars }, (_, i) => {
|
||||
const t = i / bars;
|
||||
const v = Math.abs(
|
||||
Math.sin(t * Math.PI * 7.3) * 0.55 +
|
||||
Math.sin(t * Math.PI * 2.1 + 1.2) * 0.3 +
|
||||
Math.sin(t * Math.PI * 17.0 + 0.4) * 0.15
|
||||
);
|
||||
return 0.18 + v * 0.82;
|
||||
}),
|
||||
[bars],
|
||||
);
|
||||
return (
|
||||
<div className="frs-wave" aria-hidden="true">
|
||||
{heights.map((h, i) => (
|
||||
<span key={i} className="frs-wave__bar" style={{ '--h': h, '--d': `${(i * 73) % 1400}ms` }} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Preflight panel ───────────────────────────────────────────────────── */
|
||||
/* ── Preflight panel — LED check rows ──────────────────────────────────── */
|
||||
|
||||
function PreflightPanel({ report, loading, onRecheck }) {
|
||||
const { t } = useTranslation();
|
||||
@@ -58,78 +72,74 @@ function PreflightPanel({ report, loading, onRecheck }) {
|
||||
}
|
||||
if (!report) return null;
|
||||
return (
|
||||
<div className="swiz-checklist">
|
||||
<div className="swiz-check-header">
|
||||
<span className="swiz-check-header__label">{t('setup.system_preflight')}</span>
|
||||
<Button variant="ghost" size="sm" onClick={onRecheck} leading={<RefreshCw size={12} />}>
|
||||
{t('setup.recheck')}
|
||||
</Button>
|
||||
</div>
|
||||
<section className="frs-panel">
|
||||
<h2 className="frs-panel__title">
|
||||
{t('setup.system_preflight')}
|
||||
<button type="button" className="frs-btn frs-btn--quiet swiz-recheck" onClick={onRecheck}>
|
||||
↻ {t('setup.recheck')}
|
||||
</button>
|
||||
</h2>
|
||||
{report.checks.map((c) => (
|
||||
<div key={c.id} className="setup-wizard__row swiz-check-row" style={{ alignItems: 'flex-start', padding: '8px 4px' }}>
|
||||
<span className={`swiz-check-icon swiz-check-icon--${c.status}`}>
|
||||
{CHECK_ICON[c.status] || null}
|
||||
</span>
|
||||
<div className="setup-wizard__row-body">
|
||||
<span className="setup-wizard__row-title">{c.label}</span>
|
||||
<span className="setup-wizard__muted" style={{ whiteSpace: 'normal' }}>{c.detail}</span>
|
||||
<div key={c.id} className={`frs-check frs-check--${c.status}`}>
|
||||
<span className="frs-check__led" aria-hidden="true" />
|
||||
<div className="frs-check__body">
|
||||
<span className="frs-check__title">{c.label}</span>
|
||||
<span className="frs-check__detail">{c.detail}</span>
|
||||
{c.fix && c.status !== 'pass' && (
|
||||
<span className="setup-wizard__muted" style={{
|
||||
color: c.status === 'fail' ? 'var(--color-danger)' : 'var(--color-warn, #fabd2f)',
|
||||
marginTop: 2,
|
||||
whiteSpace: 'normal',
|
||||
}}>
|
||||
→ {c.fix}
|
||||
</span>
|
||||
<span className="frs-check__fix">→ {c.fix}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Stepper nav with connectors ───────────────────────────────────────── */
|
||||
/* ── LED stepper rail ──────────────────────────────────────────────────── */
|
||||
|
||||
function StepperNav({ step, onStep }) {
|
||||
const { t } = useTranslation();
|
||||
const stepLabels = [t('setup.welcome'), t('setup.system_check'), t('setup.install_models'), t('setup.pick_engines'), t('setup.try_dictation')];
|
||||
// Three steps, no welcome ceremony: the journey rail + setup page already
|
||||
// oriented the user. Models + engines share one act (required gate +
|
||||
// optional extras).
|
||||
const stepLabels = [t('setup.system_check'), t('firstrun.stage_models', 'Models & engines'), t('setup.try_dictation')];
|
||||
return (
|
||||
<div className="setup-wizard__steps" data-tauri-drag-region>
|
||||
<nav className="frs-wsteps" data-tauri-drag-region>
|
||||
{stepLabels.map((label, i) => (
|
||||
<React.Fragment key={label}>
|
||||
{i > 0 && (
|
||||
<span className={`setup-wizard__step-connector${step > i - 1 ? ' setup-wizard__step-connector--done' : ''}`} />
|
||||
)}
|
||||
<button
|
||||
className={[
|
||||
'setup-wizard__step',
|
||||
step === i ? 'setup-wizard__step--active' : '',
|
||||
step > i ? 'setup-wizard__step--done' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
onClick={() => onStep(i)}
|
||||
type="button"
|
||||
aria-current={step === i ? 'step' : undefined}
|
||||
aria-label={`Step ${i + 1}: ${label}${step > i ? ' (completed)' : ''}`}
|
||||
>
|
||||
{step > i ? '✓ ' : `${i + 1}. `}{label}
|
||||
</button>
|
||||
</React.Fragment>
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
className={[
|
||||
'frs-wstep',
|
||||
step === i ? 'is-active' : '',
|
||||
step > i ? 'is-done' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
onClick={() => onStep(i)}
|
||||
aria-current={step === i ? 'step' : undefined}
|
||||
aria-label={`Step ${i + 1}: ${label}${step > i ? ' (completed)' : ''}`}
|
||||
>
|
||||
<span className="frs-wstep__led" aria-hidden="true" />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Main wizard component ─────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* First-run / "no models installed" gate.
|
||||
* First-run / "no models installed" gate — the final act of the first-run
|
||||
* journey (setup → install → models/engines). Rendered in the same studio
|
||||
* console design system (frs-*) so the handoff from the install splash is
|
||||
* seamless.
|
||||
*
|
||||
* Flow:
|
||||
* 0. Welcome — hero + explainer + "continue"
|
||||
* 1. System — /setup/preflight results
|
||||
* 2. Models — ModelStoreTab, unlocks on models_ready
|
||||
* 3. Engines — EnginesTab + "Enter studio"
|
||||
* 0. Welcome — what's left to do
|
||||
* 1. System — /setup/preflight results
|
||||
* 2. Models & engines — ModelStoreTab (required, gates continue) +
|
||||
* EnginesTab (optional) in one act
|
||||
* 3. Dictation — guided demo, then "Enter studio"
|
||||
*/
|
||||
export default function SetupWizard({ onReady }) {
|
||||
const { t } = useTranslation();
|
||||
@@ -144,7 +154,7 @@ export default function SetupWizard({ onReady }) {
|
||||
|
||||
// Poll setup status every 4s while on Models step
|
||||
useEffect(() => {
|
||||
if (step !== 2) return;
|
||||
if (step !== 1) return;
|
||||
const iv = setInterval(() => setupQuery.refetch(), 4000);
|
||||
return () => clearInterval(iv);
|
||||
}, [step, setupQuery]);
|
||||
@@ -156,191 +166,153 @@ export default function SetupWizard({ onReady }) {
|
||||
|
||||
const cachePath = status?.hf_cache_dir || '~/.cache/huggingface';
|
||||
|
||||
const WELCOME_CARDS = [
|
||||
{
|
||||
icon: <Monitor size={16} />,
|
||||
title: t('setup.system_check'),
|
||||
desc: t('setup.system_check_desc'),
|
||||
},
|
||||
{
|
||||
icon: <Download size={16} />,
|
||||
title: t('setup.install_models'),
|
||||
desc: t('setup.install_models_desc'),
|
||||
},
|
||||
{
|
||||
icon: <Cog size={16} />,
|
||||
title: t('setup.pick_engines'),
|
||||
desc: t('setup.pick_engines_desc'),
|
||||
},
|
||||
const STEP_SUBTITLES = [
|
||||
t('setup.system_check_desc'),
|
||||
t('setup.install_models_desc'),
|
||||
t('setup.try_dictation'),
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="setup-wizard">
|
||||
<StepperNav step={step} onStep={setStep} />
|
||||
<div className="frs swiz">
|
||||
<div className="frs__atmo" aria-hidden="true" />
|
||||
<div className="frs__deck">
|
||||
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
onDoubleClick={doubleClickMaximize}
|
||||
className="setup-wizard__hero"
|
||||
>
|
||||
<img src="/favicon.svg" alt="" className="setup-wizard__logo" />
|
||||
<div className="setup-wizard__hero-text">
|
||||
<h1 data-tauri-drag-region>OmniVoice Studio</h1>
|
||||
<span className="setup-wizard__sub" data-tauri-drag-region>
|
||||
{t('setup.hero_desc')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 0. Welcome */}
|
||||
{step === 0 && (
|
||||
<div className="swiz-slide" key="step-0">
|
||||
<div className="setup-wizard__embed">
|
||||
<div className="setup-wizard__welcome">
|
||||
<div className="setup-wizard__welcome-grid">
|
||||
{WELCOME_CARDS.map((card, i) => (
|
||||
<div className="swiz-welcome-card" key={i}>
|
||||
<div className="swiz-welcome-card__icon">{card.icon}</div>
|
||||
<div className="swiz-welcome-card__body">
|
||||
<span className="swiz-welcome-card__title">{card.title}</span>
|
||||
<p className="swiz-welcome-card__desc">{card.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="swiz-welcome-note">
|
||||
{t('setup.first_run')}
|
||||
</p>
|
||||
{/* ── Masthead: identical identity to setup + install acts ──────── */}
|
||||
<header
|
||||
className="frs__mast frs-rise"
|
||||
style={{ '--rise': 0 }}
|
||||
data-tauri-drag-region
|
||||
onDoubleClick={doubleClickMaximize}
|
||||
>
|
||||
<Waveform />
|
||||
<div className="frs__mast-row">
|
||||
<div className="frs__mast-text">
|
||||
<h1 className="frs__title" data-tauri-drag-region>OmniVoice Studio</h1>
|
||||
<p className="frs__subtitle" data-tauri-drag-region>{STEP_SUBTITLES[step]}</p>
|
||||
</div>
|
||||
<div className="frs__mast-meta">
|
||||
<StepperNav step={step} onStep={setStep} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="setup-wizard__nav">
|
||||
<span />
|
||||
<Button
|
||||
variant="primary" size="sm"
|
||||
onClick={() => setStep(1)}
|
||||
trailing={<ArrowRight size={14} />}
|
||||
>
|
||||
{t('setup.get_started')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* 1. System check */}
|
||||
{step === 1 && (
|
||||
<div className="swiz-slide" key="step-1">
|
||||
<div className="setup-wizard__embed">
|
||||
<PreflightPanel report={pre} loading={preLoading} onRecheck={recheckPreflight} />
|
||||
</div>
|
||||
<div className="setup-wizard__nav">
|
||||
<Button variant="ghost" onClick={() => setStep(0)}>{t('setup.back')}</Button>
|
||||
<Button
|
||||
variant={preflightOk ? 'primary' : 'ghost'}
|
||||
onClick={() => setStep(2)}
|
||||
trailing={<ArrowRight size={14} />}
|
||||
disabled={!preflightOk}
|
||||
title={preflightOk ? '' : t('setup.resolve_blockers')}
|
||||
>
|
||||
{preflightOk
|
||||
? (pre?.has_warnings ? t('setup.continue_warn') : t('setup.continue_ok'))
|
||||
: t('setup.continue_blocked')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 2. Models */}
|
||||
{step === 2 && (
|
||||
<div className="swiz-slide" key="step-2">
|
||||
<div className="setup-wizard__embed">
|
||||
<ModelStoreTab info={null} modelBadge={null} />
|
||||
{!modelsReady && status?.missing?.length > 0 && (
|
||||
<p className="setup-wizard__muted swiz-missing" style={{ marginTop: 8 }}>
|
||||
{t('setup.still_needed')}{' '}
|
||||
{status.missing.map(m => m.label).join(', ')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="setup-wizard__nav">
|
||||
<Button variant="ghost" onClick={() => setStep(1)}>{t('setup.back')}</Button>
|
||||
<Button
|
||||
variant={modelsReady ? 'primary' : 'ghost'}
|
||||
onClick={() => setStep(3)}
|
||||
trailing={<ArrowRight size={14} />}
|
||||
disabled={!modelsReady}
|
||||
title={modelsReady ? '' : t('setup.install_required_models')}
|
||||
>
|
||||
{modelsReady
|
||||
? t('setup.models_ready')
|
||||
: t('setup.waiting_models')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 3. Engines */}
|
||||
{step === 3 && (
|
||||
<div className="swiz-slide" key="step-3">
|
||||
<div className="setup-wizard__embed">
|
||||
<EnginesTab />
|
||||
</div>
|
||||
<div className="setup-wizard__nav">
|
||||
<Button variant="ghost" onClick={() => setStep(2)}>{t('setup.back')}</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => setStep(4)}
|
||||
leading={<CheckCircle size={14} />}
|
||||
>
|
||||
{t('setup.next_try_dictation')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 4. Dictation — guided walkthrough. Skippable (per cross-platform
|
||||
parity rule: some users genuinely don't want dictation). */}
|
||||
{step === 4 && (
|
||||
<div className="swiz-slide" key="step-4">
|
||||
<div className="setup-wizard__embed">
|
||||
<DictationDemo />
|
||||
</div>
|
||||
<div className="setup-wizard__nav">
|
||||
<Button variant="ghost" onClick={() => setStep(3)}>{t('setup.back')}</Button>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Button variant="subtle" onClick={onReady}>{t('common.cancel')}</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={onReady}
|
||||
leading={<CheckCircle size={14} />}
|
||||
{/* 0. System check — first thing a user sees: the probe auto-runs,
|
||||
no welcome ceremony (the journey rail + setup page already
|
||||
oriented them). */}
|
||||
{step === 0 && (
|
||||
<div className="swiz-slide" key="step-0">
|
||||
<div className="frs-rise" style={{ '--rise': 1 }}>
|
||||
<PreflightPanel report={pre} loading={preLoading} onRecheck={recheckPreflight} />
|
||||
</div>
|
||||
<div className="frs-wnav frs-rise" style={{ '--rise': 2 }}>
|
||||
<span />
|
||||
<button
|
||||
type="button"
|
||||
className={`frs-btn frs-btn--primary ${preflightOk ? 'is-armed' : ''}`}
|
||||
onClick={() => setStep(1)}
|
||||
disabled={!preflightOk}
|
||||
title={preflightOk ? '' : t('setup.resolve_blockers')}
|
||||
>
|
||||
{t('setup.enter_studio')}
|
||||
</Button>
|
||||
<span className="frs-btn__led" aria-hidden="true" />
|
||||
{preflightOk
|
||||
? (pre?.has_warnings ? t('setup.continue_warn') : t('setup.continue_ok'))
|
||||
: t('setup.continue_blocked')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!status && step > 1 && (
|
||||
<div className="swiz-status-loading">
|
||||
<Loader className="spinner" size={14} /> {t('setup.checking')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="setup-wizard__footnote">
|
||||
{t('setup.footer_downloads')} <code>huggingface.co</code>
|
||||
<span style={{ margin: '0 2px' }}>·</span>
|
||||
{t('setup_extra.cache_label')} <code>{shortenPath(cachePath)}</code>
|
||||
{'__TAURI_INTERNALS__' in window && cachePath && (
|
||||
<button
|
||||
className="setup-wizard__footnote-link"
|
||||
onClick={() => revealPath(cachePath)}
|
||||
title={t('setup.open_finder')}
|
||||
>
|
||||
<FolderOpen size={10} style={{ verticalAlign: '-1px', marginRight: 2 }} />
|
||||
{t('setup.open')}
|
||||
</button>
|
||||
)}
|
||||
</p>
|
||||
|
||||
{/* 1. Models & engines — ONE unified list: every installable is a
|
||||
row of the same grammar (LED · name · chip · size · action).
|
||||
Required models gate continue; engines and the optional tail
|
||||
ride in the same inventory. */}
|
||||
{step === 1 && (
|
||||
<div className="swiz-slide" key="step-1">
|
||||
<section className="frs-panel frs-rise" style={{ '--rise': 1 }}>
|
||||
<h2 className="frs-panel__title">{t('firstrun.stage_models', 'Models & engines')}</h2>
|
||||
<WizardLibrary />
|
||||
{!modelsReady && status?.missing?.length > 0 && (
|
||||
<p className="swiz-note swiz-note--warn">
|
||||
{t('setup.still_needed')}{' '}
|
||||
{status.missing.map(m => m.label).join(', ')}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
<div className="frs-wnav frs-rise" style={{ '--rise': 2 }}>
|
||||
<button type="button" className="frs-btn frs-btn--quiet" onClick={() => setStep(0)}>
|
||||
← {t('setup.back')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`frs-btn frs-btn--primary ${modelsReady ? 'is-armed' : ''}`}
|
||||
onClick={() => setStep(2)}
|
||||
disabled={!modelsReady}
|
||||
title={modelsReady ? '' : t('setup.install_required_models')}
|
||||
>
|
||||
<span className="frs-btn__led" aria-hidden="true" />
|
||||
{modelsReady ? t('setup.models_ready') : t('setup.waiting_models')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 2. Dictation — guided walkthrough. Skippable (per cross-platform
|
||||
parity rule: some users genuinely don't want dictation). */}
|
||||
{step === 2 && (
|
||||
<div className="swiz-slide" key="step-2">
|
||||
<section className="frs-panel frs-rise" style={{ '--rise': 1 }}>
|
||||
<h2 className="frs-panel__title">{t('setup.try_dictation')}</h2>
|
||||
<div className="frs-embed">
|
||||
<DictationDemo />
|
||||
</div>
|
||||
</section>
|
||||
<div className="frs-wnav frs-rise" style={{ '--rise': 2 }}>
|
||||
<button type="button" className="frs-btn frs-btn--quiet" onClick={() => setStep(1)}>
|
||||
← {t('setup.back')}
|
||||
</button>
|
||||
<div className="frs-wnav__group">
|
||||
<button type="button" className="frs-btn frs-btn--quiet" onClick={onReady}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="frs-btn frs-btn--primary is-armed"
|
||||
onClick={onReady}
|
||||
>
|
||||
<span className="frs-btn__led" aria-hidden="true" />
|
||||
{t('setup.enter_studio')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!status && step > 0 && (
|
||||
<div className="swiz-loading">
|
||||
<Loader className="spinner" size={14} /> {t('setup.checking')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<footer className="frs__foot">
|
||||
<div className="frs__foot-row">
|
||||
<span className="frs__totals">
|
||||
{t('setup.footer_downloads')} <code>huggingface.co</code>
|
||||
<span className="frs__totals-sep" aria-hidden="true">·</span>
|
||||
{t('setup_extra.cache_label')} <code>{shortenPath(cachePath)}</code>
|
||||
{'__TAURI_INTERNALS__' in window && cachePath && (
|
||||
<button
|
||||
type="button"
|
||||
className="frs-btn frs-btn--quiet"
|
||||
onClick={() => revealPath(cachePath)}
|
||||
title={t('setup.open_finder')}
|
||||
>
|
||||
{t('setup.open')}
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -160,10 +160,6 @@ function LicenseView() {
|
||||
<summary>{t('enterprise_faq.q_watermark')}</summary>
|
||||
<p>{t('enterprise_faq.a_watermark')}</p>
|
||||
</details>
|
||||
<details className="ent-faq__item">
|
||||
<summary>{t('enterprise_faq.q_apache')}</summary>
|
||||
<p>{t('enterprise_faq.a_apache')}</p>
|
||||
</details>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { toastErrorWithReport } from '../utils/errorToast';
|
||||
import {
|
||||
ArrowLeft, Fingerprint, Wand2, Lock, Unlock, Trash2, Play, Save,
|
||||
FolderOpen, Volume2, Clock, Pencil, Check, X, Sparkles,
|
||||
@@ -81,7 +82,7 @@ export default function VoiceProfile({ voiceId, onBack, onOpenProject, onDeleted
|
||||
setEditing(false);
|
||||
toast.success(t('voice_profile.saved'));
|
||||
} catch (e) {
|
||||
toast.error(t('voice_profile.save_failed', { message: e.message }));
|
||||
toastErrorWithReport(t('voice_profile.save_failed', { message: e.message }), e);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -104,7 +105,7 @@ export default function VoiceProfile({ voiceId, onBack, onOpenProject, onDeleted
|
||||
toast.success(t('voice_profile.deleted'));
|
||||
onDeleted?.();
|
||||
} catch (e) {
|
||||
toast.error(t('voice_profile.delete_failed', { message: e.message }));
|
||||
toastErrorWithReport(t('voice_profile.delete_failed', { message: e.message }), e);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -139,7 +140,7 @@ export default function VoiceProfile({ voiceId, onBack, onOpenProject, onDeleted
|
||||
setTestAudioUrl(url);
|
||||
setTimeout(() => testAudioRef.current?.play?.(), 80);
|
||||
} catch (e) {
|
||||
toast.error(t('voice_profile.gen_failed', { message: e.message }));
|
||||
toastErrorWithReport(t('voice_profile.gen_failed', { message: e.message }), e);
|
||||
} finally {
|
||||
setTestGenerating(false);
|
||||
}
|
||||
|
||||
@@ -87,6 +87,11 @@ export interface DubSlice {
|
||||
dubLang: string;
|
||||
dubLangCode: string;
|
||||
|
||||
// Optional speaker-count hint passed to pyannote diarization (#274). null =
|
||||
// let pyannote auto-detect; a positive int forces that many speakers when
|
||||
// auto-detect collapses a multi-speaker clip to one.
|
||||
dubNumSpeakers: number | null;
|
||||
|
||||
// ── Generation options ────────────────────────────────────────────────
|
||||
dubInstruct: string;
|
||||
preserveBg: boolean;
|
||||
@@ -136,6 +141,7 @@ export interface DubSlice {
|
||||
setDubTracks: (v: Updater<string[]>) => void;
|
||||
setDubLang: (v: Updater<string>) => void;
|
||||
setDubLangCode: (v: Updater<string>) => void;
|
||||
setDubNumSpeakers: (v: Updater<number | null>) => void;
|
||||
setDubInstruct: (v: Updater<string>) => void;
|
||||
setPreserveBg: (v: Updater<boolean>) => void;
|
||||
setDefaultTrack: (v: Updater<string>) => void;
|
||||
@@ -152,7 +158,7 @@ const INITIAL: Omit<DubSlice,
|
||||
| 'setDubPrepProgress' | 'setDubCurrentSegId'
|
||||
| 'setDubProgress' | 'setDubError' | 'setDubFailure' | 'setIsTranslating' | 'setDubSegments'
|
||||
| 'setDubTranscript' | 'setDubFilename' | 'setDubDuration' | 'setDubTracks'
|
||||
| 'setDubLang' | 'setDubLangCode' | 'setDubInstruct' | 'setPreserveBg'
|
||||
| 'setDubLang' | 'setDubLangCode' | 'setDubNumSpeakers' | 'setDubInstruct' | 'setPreserveBg'
|
||||
| 'setDefaultTrack' | 'setExportTracks' | 'setPreviewSegIds' | 'setSpeakerClones'
|
||||
| 'setSegmentEffectPreset' | 'setAvailableEffectPresets' | 'resetDubState'
|
||||
> = {
|
||||
@@ -174,6 +180,7 @@ const INITIAL: Omit<DubSlice,
|
||||
dubTracks: [],
|
||||
dubLang: 'Auto',
|
||||
dubLangCode: 'en',
|
||||
dubNumSpeakers: null,
|
||||
dubInstruct: '',
|
||||
preserveBg: true,
|
||||
defaultTrack: 'original',
|
||||
@@ -205,6 +212,7 @@ export const createDubSlice: StateCreator<DubSlice, [], [], DubSlice> = (set, ge
|
||||
setDubTracks: (v) => set((s) => ({ dubTracks: resolve(v, s.dubTracks) })),
|
||||
setDubLang: (v) => set((s) => ({ dubLang: resolve(v, s.dubLang) })),
|
||||
setDubLangCode: (v) => set((s) => ({ dubLangCode: resolve(v, s.dubLangCode) })),
|
||||
setDubNumSpeakers: (v) => set((s) => ({ dubNumSpeakers: resolve(v, s.dubNumSpeakers) })),
|
||||
setDubInstruct: (v) => set((s) => ({ dubInstruct: resolve(v, s.dubInstruct) })),
|
||||
setPreserveBg: (v) => set((s) => ({ preserveBg: resolve(v, s.preserveBg) })),
|
||||
setDefaultTrack: (v) => set((s) => ({ defaultTrack: resolve(v, s.defaultTrack) })),
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* breadcrumbs — local-only ring of recent UI actions for bug reports.
|
||||
*
|
||||
* The cheapest repro-step generator there is: when a report goes out, the
|
||||
* last ~20 action names ride along as a "Recent actions" section so the
|
||||
* maintainer sees "switched engine → started dub → export failed" instead
|
||||
* of guessing.
|
||||
*
|
||||
* Privacy rules (stricter than the scrubber):
|
||||
* - action NAMES only — never text content, file names, paths, or URLs
|
||||
* - callers pass fixed strings like 'generate:start' or 'view:settings';
|
||||
* anything dynamic must be from a closed set (mode names, engine ids)
|
||||
* Lives in memory only — never persisted, never sent anywhere except inside
|
||||
* a report body the user reviews on github.com.
|
||||
*/
|
||||
|
||||
const MAX = 20;
|
||||
const ring = [];
|
||||
|
||||
export function addBreadcrumb(action) {
|
||||
if (!action) return;
|
||||
const now = Date.now();
|
||||
const last = ring[ring.length - 1];
|
||||
// Collapse immediate repeats (a re-render storm must not flush the ring).
|
||||
if (last && last.action === action && now - last.t < 2000) {
|
||||
last.t = now;
|
||||
return;
|
||||
}
|
||||
ring.push({ t: now, action: String(action).slice(0, 60) });
|
||||
if (ring.length > MAX) ring.shift();
|
||||
}
|
||||
|
||||
export function getBreadcrumbs() {
|
||||
return ring.slice();
|
||||
}
|
||||
|
||||
/** "12:03:05 view:dub" lines, oldest first — ready for the report body. */
|
||||
export function formatBreadcrumbs() {
|
||||
return ring
|
||||
.map((b) => `${new Date(b.t).toLocaleTimeString('en-GB')} ${b.action}`)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
export function clearBreadcrumbs() {
|
||||
ring.length = 0;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
import { addBreadcrumb, getBreadcrumbs, formatBreadcrumbs, clearBreadcrumbs } from './breadcrumbs';
|
||||
|
||||
describe('breadcrumbs', () => {
|
||||
beforeEach(() => clearBreadcrumbs());
|
||||
|
||||
it('records actions in order', () => {
|
||||
addBreadcrumb('view:clone');
|
||||
addBreadcrumb('generate:start (clone)');
|
||||
expect(getBreadcrumbs().map((b) => b.action)).toEqual([
|
||||
'view:clone',
|
||||
'generate:start (clone)',
|
||||
]);
|
||||
});
|
||||
|
||||
it('collapses immediate repeats so render storms cannot flush the ring', () => {
|
||||
addBreadcrumb('view:dub');
|
||||
addBreadcrumb('view:dub');
|
||||
addBreadcrumb('view:dub');
|
||||
expect(getBreadcrumbs()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('caps the ring at 20', () => {
|
||||
vi.useFakeTimers();
|
||||
for (let i = 0; i < 30; i++) {
|
||||
vi.advanceTimersByTime(3000); // past the repeat-collapse window
|
||||
addBreadcrumb(`action-${i}`);
|
||||
}
|
||||
vi.useRealTimers();
|
||||
const crumbs = getBreadcrumbs();
|
||||
expect(crumbs).toHaveLength(20);
|
||||
expect(crumbs[0].action).toBe('action-10');
|
||||
});
|
||||
|
||||
it('formats one line per crumb', () => {
|
||||
addBreadcrumb('view:settings');
|
||||
const out = formatBreadcrumbs();
|
||||
expect(out).toMatch(/\d{2}:\d{2}:\d{2} view:settings/);
|
||||
});
|
||||
|
||||
it('handles empty ring', () => {
|
||||
expect(formatBreadcrumbs()).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* bugReport — shared builder for the prefilled GitHub Issues URL.
|
||||
*
|
||||
* Single source of truth for everything that can leave the machine as a
|
||||
* bug report: ReportBugButton (Settings → About), the ErrorBoundary's
|
||||
* "Report this bug" action, and error toasts all funnel through
|
||||
* `buildBugReportUrl()`. The user always reviews the prefilled form on
|
||||
* github.com before anything is submitted — we never POST, never hold a
|
||||
* token (CLAUDE.md Capability 2).
|
||||
*
|
||||
* `scrubText` is the frontend twin of backend/core/scrub.py and must stay
|
||||
* at least as strict for the shapes a webview can see (home paths +
|
||||
* credential-shaped substrings; env vars aren't reachable from JS).
|
||||
*/
|
||||
/* global __APP_VERSION__ -- injected by Vite at build time (vite.config define) */
|
||||
import { API } from '../api/client';
|
||||
import { formatBreadcrumbs } from './breadcrumbs';
|
||||
|
||||
export const ISSUES_URL = 'https://github.com/debpalash/OmniVoice-Studio/issues/new';
|
||||
|
||||
const APP_VERSION = (typeof __APP_VERSION__ !== 'undefined' && __APP_VERSION__) || 'unknown';
|
||||
|
||||
export const REDACTED = '***REDACTED***';
|
||||
|
||||
// Thresholds mirror backend/core/scrub.py: long enough that identifiers
|
||||
// like `hf_hub` or `sk-learn` survive, short enough that real tokens don't.
|
||||
const TOKEN_PATTERNS = [
|
||||
/hf_[A-Za-z0-9]{30,}/g, // HuggingFace
|
||||
/github_pat_[A-Za-z0-9_]{20,}/g, // GitHub fine-grained PAT
|
||||
/gh[pousr]_[A-Za-z0-9]{30,}/g, // GitHub classic tokens
|
||||
/sk-[A-Za-z0-9_-]{20,}/g, // OpenAI-style API keys
|
||||
];
|
||||
|
||||
const HOME_PATTERNS = [
|
||||
/\/Users\/[^/\s"']+/g, // macOS
|
||||
/\/home\/[^/\s"']+/g, // Linux
|
||||
/[A-Za-z]:\\Users\\[^\\\s"']+/g, // Windows
|
||||
];
|
||||
|
||||
/** Redact credential-shaped substrings and home directories. */
|
||||
export function scrubText(text) {
|
||||
if (text == null) return '';
|
||||
let s = String(text);
|
||||
for (const pat of TOKEN_PATTERNS) s = s.replace(pat, REDACTED);
|
||||
for (const pat of HOME_PATTERNS) s = s.replace(pat, '~');
|
||||
return s;
|
||||
}
|
||||
|
||||
// GitHub truncates very long prefill URLs; keep the encoded result well
|
||||
// under the ~8k practical ceiling so the user never loses the form.
|
||||
const MAX_STACK_CHARS = 1800;
|
||||
const MAX_BODY_CHARS = 6000;
|
||||
|
||||
/** Environment lines for the report body. Best-effort — every fetch is
|
||||
* optional so a dead backend still yields a usable report. */
|
||||
export async function captureContext() {
|
||||
const lines = [
|
||||
`**Version:** \`${APP_VERSION}\``,
|
||||
`**Platform:** \`${navigator?.userAgent || 'unknown'}\``,
|
||||
];
|
||||
|
||||
try {
|
||||
const r = await fetch(`${API}/system/info`);
|
||||
if (r.ok) {
|
||||
const j = await r.json();
|
||||
if (j?.os_version) lines.push(`**OS:** \`${scrubText(j.os_version)}\``);
|
||||
else if (j?.platform) lines.push(`**OS:** \`${j.platform}\``);
|
||||
if (j?.python) lines.push(`**Python:** \`${j.python}\``);
|
||||
if (j?.device) lines.push(`**Compute device:** \`${scrubText(j.device)}\``);
|
||||
if (j?.gpu_name) {
|
||||
const vram = j?.vram_total_gb ? ` (${j.vram_total_gb} GB VRAM)` : '';
|
||||
lines.push(`**GPU:** \`${scrubText(j.gpu_name)}${vram}\``);
|
||||
}
|
||||
if (j?.cpu_model) lines.push(`**CPU:** \`${scrubText(j.cpu_model)}\``);
|
||||
if (j?.ram_total_gb) lines.push(`**RAM:** \`${j.ram_total_gb} GB\``);
|
||||
if (j?.disk_free_gb) lines.push(`**Disk free:** \`${j.disk_free_gb} GB\``);
|
||||
}
|
||||
} catch { /* backend probably not up yet */ }
|
||||
|
||||
try {
|
||||
const r = await fetch(`${API}/engines`);
|
||||
if (r.ok) {
|
||||
const j = await r.json();
|
||||
const active = j?.tts?.active;
|
||||
if (active) lines.push(`**Active TTS engine:** \`${active}\``);
|
||||
}
|
||||
} catch { /* noop */ }
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the prefilled GitHub Issues URL.
|
||||
*
|
||||
* @param {object} [opts]
|
||||
* @param {string} [opts.title] Issue title prefill (defaults to '[Bug] ').
|
||||
* @param {Error|string} [opts.error] Error to embed — message + stack are
|
||||
* scrubbed and truncated into an "## Error" section so the report opens
|
||||
* with the actual failure attached.
|
||||
*/
|
||||
export async function buildBugReportUrl({ title = '[Bug] ', error } = {}) {
|
||||
const ctx = await captureContext();
|
||||
|
||||
const errorSection = [];
|
||||
if (error) {
|
||||
const msg = scrubText(error?.message || String(error));
|
||||
// Seed the title with the failure so the issue list stays scannable;
|
||||
// the user can still edit it on github.com before submitting.
|
||||
if (title === '[Bug] ' && msg) title = `[Bug] ${msg.slice(0, 80)}`;
|
||||
let stack = error?.stack ? scrubText(error.stack) : '';
|
||||
if (stack.length > MAX_STACK_CHARS) stack = `${stack.slice(0, MAX_STACK_CHARS)}\n… (truncated)`;
|
||||
errorSection.push(
|
||||
'## Error',
|
||||
'',
|
||||
'```',
|
||||
msg,
|
||||
...(stack && stack !== msg ? [stack] : []),
|
||||
'```',
|
||||
'',
|
||||
);
|
||||
}
|
||||
|
||||
// Action names only (see utils/breadcrumbs.js privacy rules) — still
|
||||
// scrubbed as belt-and-braces, and the user reviews it all on github.com.
|
||||
const crumbs = scrubText(formatBreadcrumbs());
|
||||
const crumbSection = crumbs
|
||||
? ['## Recent actions', '', '```', crumbs, '```', '']
|
||||
: [];
|
||||
|
||||
let body = [
|
||||
'<!-- Click Submit at the bottom of this page to file the issue.',
|
||||
' Review the auto-captured environment info below and add anything',
|
||||
' about what you were doing when the bug happened. -->',
|
||||
'',
|
||||
'## Describe the bug',
|
||||
'',
|
||||
'<!-- e.g. "Synthesize failed in Design mode after picking Narrator personality" -->',
|
||||
'',
|
||||
...errorSection,
|
||||
'## Environment',
|
||||
'',
|
||||
ctx,
|
||||
'',
|
||||
...crumbSection,
|
||||
'## What I was doing',
|
||||
'',
|
||||
'<!-- step-by-step would help us reproduce -->',
|
||||
'',
|
||||
].join('\n');
|
||||
if (body.length > MAX_BODY_CHARS) body = `${body.slice(0, MAX_BODY_CHARS)}\n… (truncated)`;
|
||||
|
||||
return `${ISSUES_URL}?title=${encodeURIComponent(title)}&labels=${encodeURIComponent('bug')}&body=${encodeURIComponent(body)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* GitHub issue-search URL for "has someone already hit this?" — opened in
|
||||
* the user's browser before they file a duplicate. Search terms come from
|
||||
* the scrubbed error message with noise (numbers, paths, quotes) stripped
|
||||
* so the query matches across machines.
|
||||
*/
|
||||
export function buildIssueSearchUrl(error) {
|
||||
const msg = scrubText(error?.message || String(error || ''));
|
||||
const terms = msg
|
||||
.replace(/[^a-zA-Z\s]/g, ' ') // drop numbers/punctuation — machine-specific
|
||||
.split(/\s+/)
|
||||
.filter((w) => w.length > 2)
|
||||
.slice(0, 6)
|
||||
.join(' ');
|
||||
const q = `is:issue ${terms}`.trim();
|
||||
return `https://github.com/debpalash/OmniVoice-Studio/issues?q=${encodeURIComponent(q)}`;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
import { scrubText, buildBugReportUrl, ISSUES_URL, REDACTED } from './bugReport';
|
||||
|
||||
describe('scrubText — frontend twin of backend/core/scrub.py', () => {
|
||||
it.each([
|
||||
['/Users/alice/Library/Logs/app.log', '~/Library/Logs/app.log'],
|
||||
['/home/bob/.omnivoice/omnivoice.log', '~/.omnivoice/omnivoice.log'],
|
||||
['C:\\Users\\carol\\AppData\\Roaming\\OmniVoice', '~\\AppData\\Roaming\\OmniVoice'],
|
||||
])('redacts home path %s', (raw, expected) => {
|
||||
expect(scrubText(raw)).toBe(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[`hf_${'A'.repeat(34)}`],
|
||||
[`ghp_${'B'.repeat(36)}`],
|
||||
[`github_pat_${'C'.repeat(22)}`],
|
||||
[`sk-${'d'.repeat(40)}`],
|
||||
])('redacts credential-shaped %s', (secret) => {
|
||||
const out = scrubText(`auth failed: token=${secret}`);
|
||||
expect(out).not.toContain(secret);
|
||||
expect(out).toContain(REDACTED);
|
||||
});
|
||||
|
||||
it.each([['hf_hub'], ['sk-learn'], ['ghp_x']])(
|
||||
'leaves short identifier %s alone',
|
||||
(benign) => {
|
||||
expect(scrubText(`import error in ${benign}`)).toContain(benign);
|
||||
},
|
||||
);
|
||||
|
||||
it('handles null/undefined', () => {
|
||||
expect(scrubText(null)).toBe('');
|
||||
expect(scrubText(undefined)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildBugReportUrl', () => {
|
||||
beforeEach(() => {
|
||||
// Backend down — the builder must still produce a usable URL.
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('ECONNREFUSED')));
|
||||
});
|
||||
|
||||
it('targets the issues/new endpoint with bug label', async () => {
|
||||
const url = await buildBugReportUrl();
|
||||
expect(url.startsWith(`${ISSUES_URL}?`)).toBe(true);
|
||||
expect(url).toContain(`labels=${encodeURIComponent('bug')}`);
|
||||
});
|
||||
|
||||
it('embeds the scrubbed error message and stack', async () => {
|
||||
const err = new Error('cannot open /Users/alice/voice.wav');
|
||||
const url = await buildBugReportUrl({ error: err });
|
||||
const body = decodeURIComponent(url);
|
||||
expect(body).toContain('## Error');
|
||||
expect(body).toContain('cannot open ~/voice.wav');
|
||||
expect(body).not.toContain('/Users/alice');
|
||||
});
|
||||
|
||||
it('seeds the title with the error message', async () => {
|
||||
const url = await buildBugReportUrl({ error: new Error('synthesis exploded') });
|
||||
expect(decodeURIComponent(url)).toContain('[Bug] synthesis exploded');
|
||||
});
|
||||
|
||||
it('stays under the prefill URL ceiling on huge stacks', async () => {
|
||||
const err = new Error('boom');
|
||||
err.stack = 'at frame\n'.repeat(5000);
|
||||
const url = await buildBugReportUrl({ error: err });
|
||||
expect(url.length).toBeLessThan(8000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildIssueSearchUrl', () => {
|
||||
it('builds a scrubbed, noise-free search query', async () => {
|
||||
const { buildIssueSearchUrl } = await import('./bugReport');
|
||||
const url = buildIssueSearchUrl(new Error('CUDA error 700 at /home/eve/cache: illegal memory access'));
|
||||
const q = decodeURIComponent(url.split('q=')[1]);
|
||||
expect(url).toContain('github.com/debpalash/OmniVoice-Studio/issues?q=');
|
||||
expect(q).toContain('CUDA error');
|
||||
expect(q).not.toContain('700'); // machine-specific noise stripped
|
||||
expect(q).not.toContain('/home/eve'); // scrubbed + punctuation-stripped
|
||||
});
|
||||
|
||||
it('survives an empty error', async () => {
|
||||
const { buildIssueSearchUrl } = await import('./bugReport');
|
||||
expect(buildIssueSearchUrl(null)).toContain('issues?q=');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* errorToast — error toast with a "Report" action.
|
||||
*
|
||||
* Drop-in upgrade for `toast.error(message)` at call sites that have the
|
||||
* failure in hand: same toast, plus a button that opens the prefilled
|
||||
* GitHub Issues form (utils/bugReport.js) with the scrubbed error attached.
|
||||
* Nothing is sent anywhere until the user clicks Submit on github.com.
|
||||
*/
|
||||
import toast from 'react-hot-toast';
|
||||
import i18next from 'i18next';
|
||||
import { openExternal } from '../api/external';
|
||||
import { buildBugReportUrl } from './bugReport';
|
||||
|
||||
export function toastErrorWithReport(message, error) {
|
||||
const err = error instanceof Error ? error : new Error(String(error ?? message));
|
||||
toast.error(
|
||||
(tst) => (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span style={{ flex: 1 }}>{message}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary"
|
||||
style={{ flexShrink: 0, whiteSpace: 'nowrap' }}
|
||||
onClick={async () => {
|
||||
toast.dismiss(tst.id);
|
||||
try {
|
||||
await openExternal(await buildBugReportUrl({ error: err }));
|
||||
} catch (e) {
|
||||
// openExternal already falls back to window.open; if even
|
||||
// that failed there's nothing actionable left to surface.
|
||||
console.warn('[errorToast] report action failed', e);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{i18next.t('errors.report')}
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
{ duration: 8000 },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* globalErrorHandlers — last-resort surfacing for uncaught failures.
|
||||
*
|
||||
* consoleBuffer already records `window.onerror` / `unhandledrejection`
|
||||
* into the Settings → Logs → Frontend ring; this adds the user-visible
|
||||
* half: a throttled error toast with a "Report this bug" action
|
||||
* (utils/errorToast.jsx) so async failures outside any ErrorBoundary or
|
||||
* wired call site still have a path to a GitHub issue.
|
||||
*
|
||||
* Throttled per message (one toast per 30s) and filtered against known
|
||||
* benign noise — a render-loop bug must not bury the user in toasts.
|
||||
*/
|
||||
import i18next from 'i18next';
|
||||
import { toastErrorWithReport } from './errorToast';
|
||||
|
||||
const THROTTLE_MS = 30_000;
|
||||
const lastShown = new Map();
|
||||
|
||||
// Browser/webview noise that is not actionable by the user and must never
|
||||
// produce a report prompt.
|
||||
const IGNORE_PATTERNS = [
|
||||
/ResizeObserver loop/i,
|
||||
/AbortError/i,
|
||||
/Loading chunk \d+ failed/i, // transient on dev-server restarts
|
||||
/Script error\.?$/i, // opaque cross-origin errors carry no info
|
||||
];
|
||||
|
||||
function shouldShow(message) {
|
||||
if (!message || IGNORE_PATTERNS.some((p) => p.test(message))) return false;
|
||||
const key = String(message).slice(0, 200);
|
||||
const now = Date.now();
|
||||
if ((lastShown.get(key) || 0) > now - THROTTLE_MS) return false;
|
||||
lastShown.set(key, now);
|
||||
return true;
|
||||
}
|
||||
|
||||
function surface(message, error) {
|
||||
if (!shouldShow(message)) return;
|
||||
const err = error instanceof Error ? error : new Error(String(error ?? message));
|
||||
toastErrorWithReport(
|
||||
i18next.t('errors.unexpected', { message: String(message).slice(0, 140) }),
|
||||
err,
|
||||
);
|
||||
}
|
||||
|
||||
let installed = false;
|
||||
|
||||
export function installGlobalErrorHandlers() {
|
||||
if (installed || typeof window === 'undefined') return;
|
||||
installed = true;
|
||||
window.addEventListener('error', (e) => {
|
||||
surface(e?.error?.message || e.message, e.error);
|
||||
});
|
||||
window.addEventListener('unhandledrejection', (e) => {
|
||||
const r = e?.reason;
|
||||
surface(r?.message || String(r), r);
|
||||
});
|
||||
}
|
||||
+70
-1
@@ -23,7 +23,8 @@ Provides:
|
||||
- ``add_punctuation()``: Appends missing end punctuation (Chinese or English).
|
||||
"""
|
||||
|
||||
from typing import List, Optional
|
||||
import re
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
|
||||
SPLIT_PUNCTUATION = set(".,;:!?。,;:!?")
|
||||
@@ -217,3 +218,71 @@ def add_punctuation(text: str):
|
||||
text += "。" if is_chinese else "."
|
||||
|
||||
return text
|
||||
|
||||
|
||||
# Inline pause marker (issue #276): `[pause]`, `[pause 500ms]`, `[pause 1s]`,
|
||||
# `[pause 1.5s]`. Case-insensitive; whitespace around the number is tolerated.
|
||||
# A bare `[pause]` uses PAUSE_DEFAULT_MS.
|
||||
PAUSE_DEFAULT_MS = 350
|
||||
PAUSE_MAX_MS = 10_000
|
||||
_PAUSE_RE = re.compile(
|
||||
r"\[\s*pause(?:\s+(\d+(?:\.\d+)?)\s*(ms|s)?)?\s*\]",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _pause_ms(num, unit):
|
||||
"""Resolve a parsed (number, unit) pair to a clamped millisecond value."""
|
||||
if num is None:
|
||||
return PAUSE_DEFAULT_MS
|
||||
try:
|
||||
value = float(num)
|
||||
except ValueError:
|
||||
return PAUSE_DEFAULT_MS
|
||||
# Bare number or explicit "ms" -> milliseconds; "s" -> seconds.
|
||||
ms = value * 1000.0 if (unit and unit.lower() == "s") else value
|
||||
ms_int = int(round(ms))
|
||||
return max(0, min(ms_int, PAUSE_MAX_MS))
|
||||
|
||||
|
||||
def parse_pause_markers(text):
|
||||
"""Split ``text`` on inline ``[pause ...]`` markers (issue #276).
|
||||
|
||||
Returns a list of ``(span_text, pause_ms_after)`` tuples, in order, where
|
||||
``pause_ms_after`` is the silence (in milliseconds) to insert AFTER that
|
||||
span's synthesized audio. Guarantees:
|
||||
|
||||
- With no markers: ``[(text, 0)]`` -- the original text, no pause.
|
||||
- Concatenating every ``span_text`` (markers removed) reproduces the input
|
||||
minus the markers.
|
||||
- A leading marker yields a first tuple with empty ``span_text`` and the
|
||||
pause (rendered as leading silence, no audio).
|
||||
- Consecutive markers sum their durations (clamped to ``PAUSE_MAX_MS``).
|
||||
|
||||
The caller synthesizes each non-empty ``span_text`` as usual and stitches a
|
||||
silence buffer of the given length between spans -- no model changes needed.
|
||||
"""
|
||||
if not text or "[" not in text:
|
||||
return [(text, 0)]
|
||||
|
||||
segments = []
|
||||
last = 0
|
||||
pending_text = ""
|
||||
for m in _PAUSE_RE.finditer(text):
|
||||
pending_text += text[last:m.start()]
|
||||
last = m.end()
|
||||
pause = _pause_ms(m.group(1), m.group(2))
|
||||
# When two markers are adjacent (no text between), merge the silence
|
||||
# onto the previous segment instead of emitting an empty span.
|
||||
if pending_text == "" and segments:
|
||||
prev_text, prev_pause = segments[-1]
|
||||
segments[-1] = (prev_text, min(prev_pause + pause, PAUSE_MAX_MS))
|
||||
else:
|
||||
segments.append((pending_text, pause))
|
||||
pending_text = ""
|
||||
|
||||
tail = pending_text + text[last:]
|
||||
if tail or not segments:
|
||||
segments.append((tail, 0))
|
||||
|
||||
return segments
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"name": "omnivoice-studio-monorepo",
|
||||
"version": "1.0.0",
|
||||
"license": "AGPL-3.0-only",
|
||||
"private": true,
|
||||
"packageManager": "bun@1.3.11",
|
||||
"scripts": {
|
||||
|
||||
+6
-5
@@ -4,13 +4,14 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "omnivoice"
|
||||
version = "0.3.1"
|
||||
version = "0.3.5"
|
||||
description = "OmniVoice: Towards Omnilingual Zero-Shot Text-to-Speech with Diffusion Language Models"
|
||||
readme = "README.md"
|
||||
# Source-available under FSL-1.1-ALv2 (see LICENSE); each release converts to
|
||||
# Apache-2.0 two years after publication. Declared as a PEP 639 LicenseRef
|
||||
# since FSL isn't an OSI/SPDX-listed identifier.
|
||||
license = "LicenseRef-FSL-1.1-ALv2"
|
||||
# Free and open-source under the GNU Affero General Public License v3 (see
|
||||
# LICENSE). A commercial license is available for proprietary/closed-source use
|
||||
# without AGPL obligations — contact OmniVoice@palash.dev. The bundled omnivoice/
|
||||
# TTS model by Han Zhu remains Apache-2.0 upstream (Apache-2.0 is AGPL-compatible).
|
||||
license = "AGPL-3.0-only"
|
||||
requires-python = ">=3.11"
|
||||
authors = [{name = "Han Zhu"}]
|
||||
keywords = [
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"""`assign_speakers_from_diarization` overlap-weighting + label handling (#274).
|
||||
|
||||
When pyannote returns multiple speakers, each transcript segment must get the
|
||||
speaker whose turns overlap it most — so a 2-speaker diarization yields two
|
||||
distinct `Speaker N` ids, NOT a collapse to one. (The single-speaker collapse
|
||||
users see comes from pyannote's *auto-detect*, which the new `num_speakers`
|
||||
hint addresses; this test pins that the consumption side is correct.)
|
||||
"""
|
||||
from dataclasses import dataclass
|
||||
|
||||
from services.segmentation import assign_speakers_from_diarization
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Turn:
|
||||
start: float
|
||||
end: float
|
||||
|
||||
|
||||
class _FakeDiarization:
|
||||
"""Mimics pyannote's `Annotation.itertracks(yield_label=True)`."""
|
||||
def __init__(self, turns):
|
||||
self._turns = turns # list of (Turn, track_name, speaker_label)
|
||||
|
||||
def itertracks(self, yield_label=False):
|
||||
for turn, track, spk in self._turns:
|
||||
yield (turn, track, spk) if yield_label else (turn, track)
|
||||
|
||||
|
||||
def _segs(*spans):
|
||||
return [{"start": a, "end": b, "speaker_id": "Speaker 1"} for a, b in spans]
|
||||
|
||||
|
||||
def test_two_speakers_yield_two_distinct_ids():
|
||||
# Speaker_0 owns 0–5s, Speaker_1 owns 5–10s.
|
||||
diar = _FakeDiarization([
|
||||
(_Turn(0.0, 5.0), "A", "SPEAKER_00"),
|
||||
(_Turn(5.0, 10.0), "B", "SPEAKER_01"),
|
||||
])
|
||||
segs = _segs((0.5, 2.0), (6.0, 9.0))
|
||||
out = assign_speakers_from_diarization(segs, diar)
|
||||
assert out[0]["speaker_id"] == "Speaker 1" # SPEAKER_00 -> idx 1
|
||||
assert out[1]["speaker_id"] == "Speaker 2" # SPEAKER_01 -> idx 2
|
||||
assert out[0]["speaker_id"] != out[1]["speaker_id"]
|
||||
|
||||
|
||||
def test_overlap_weighted_winner():
|
||||
# Segment 2–8 overlaps SPEAKER_00 for 3s (2–5) and SPEAKER_01 for 3s...
|
||||
# but SPEAKER_01 owns 5–9 so overlap is 3s each — tie broken by max(); make
|
||||
# SPEAKER_01 clearly dominant by extending its turn.
|
||||
diar = _FakeDiarization([
|
||||
(_Turn(0.0, 5.0), "A", "SPEAKER_00"),
|
||||
(_Turn(5.0, 12.0), "B", "SPEAKER_01"),
|
||||
])
|
||||
out = assign_speakers_from_diarization(_segs((4.0, 11.0)), diar)
|
||||
# overlap: SPEAKER_00 = 1s (4–5), SPEAKER_01 = 6s (5–11) → winner SPEAKER_01
|
||||
assert out[0]["speaker_id"] == "Speaker 2"
|
||||
|
||||
|
||||
def test_midpoint_fallback_when_no_overlap():
|
||||
# Segment sits fully inside a single turn; overlap path still catches it,
|
||||
# but a zero-length segment exercises the midpoint fallback.
|
||||
diar = _FakeDiarization([(_Turn(0.0, 10.0), "A", "SPEAKER_02")])
|
||||
out = assign_speakers_from_diarization([{"start": 3.0, "end": 3.0, "speaker_id": "Speaker 1"}], diar)
|
||||
assert out[0]["speaker_id"] == "Speaker 3"
|
||||
|
||||
|
||||
def test_non_underscore_label_kept_verbatim():
|
||||
# A label that isn't `<prefix>_<int>` must not crash — kept as-is.
|
||||
diar = _FakeDiarization([(_Turn(0.0, 5.0), "A", "narrator")])
|
||||
out = assign_speakers_from_diarization(_segs((1.0, 2.0)), diar)
|
||||
assert out[0]["speaker_id"] == "narrator"
|
||||
|
||||
|
||||
def test_empty_diarization_leaves_segment_untouched():
|
||||
# No turns → no winner → segment keeps whatever it had (heuristic fallback
|
||||
# upstream owns that case).
|
||||
diar = _FakeDiarization([])
|
||||
out = assign_speakers_from_diarization(_segs((1.0, 2.0)), diar)
|
||||
assert out[0]["speaker_id"] == "Speaker 1"
|
||||
@@ -0,0 +1,112 @@
|
||||
"""core.diagnose — self-check suite behind /system/diagnose and --diagnose."""
|
||||
import pytest
|
||||
|
||||
from core import diagnose
|
||||
from core.diagnose import OK, WARN, FAIL, run_diagnostics, format_text
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def report():
|
||||
# include_network=False: the suite must come back instantly offline —
|
||||
# that's the contract the CLI and tests rely on.
|
||||
return run_diagnostics(include_network=False)
|
||||
|
||||
|
||||
def test_report_shape(report):
|
||||
assert set(report) == {"app_version", "platform", "checks", "summary"}
|
||||
ids = [c["id"] for c in report["checks"]]
|
||||
assert len(ids) == len(set(ids)), "check ids must be unique"
|
||||
for c in report["checks"]:
|
||||
assert c["status"] in (OK, WARN, FAIL)
|
||||
assert c["label"] and isinstance(c["detail"], str)
|
||||
|
||||
|
||||
def test_network_check_skippable(report):
|
||||
assert "network" not in [c["id"] for c in report["checks"]]
|
||||
|
||||
|
||||
def test_summary_consistent(report):
|
||||
s = report["summary"]
|
||||
statuses = [c["status"] for c in report["checks"]]
|
||||
assert s["passed"] == statuses.count(OK)
|
||||
assert s["warnings"] == statuses.count(WARN)
|
||||
assert s["failures"] == statuses.count(FAIL)
|
||||
assert s["ok"] == (s["failures"] == 0)
|
||||
|
||||
|
||||
def test_core_checks_present(report):
|
||||
ids = {c["id"] for c in report["checks"]}
|
||||
assert {"python", "device", "ffmpeg", "hf_token", "disk", "data_dir", "ram", "engines"} <= ids
|
||||
|
||||
|
||||
def test_low_disk_fails(monkeypatch):
|
||||
class FakeUsage:
|
||||
free = 1 * 1024 ** 3 # 1 GB — below the 2 GB fail line
|
||||
monkeypatch.setattr(diagnose.shutil, "disk_usage", lambda _p: FakeUsage())
|
||||
check = diagnose._check_disk()
|
||||
assert check["status"] == FAIL
|
||||
|
||||
|
||||
def test_unwritable_data_dir_fails(monkeypatch, tmp_path):
|
||||
missing = tmp_path / "definitely" / "not" / "there"
|
||||
monkeypatch.setattr(diagnose, "DATA_DIR", str(missing))
|
||||
check = diagnose._check_data_dir()
|
||||
assert check["status"] == FAIL
|
||||
assert check["hint"] # actionable hint required on failure
|
||||
|
||||
|
||||
def test_details_are_scrubbed(monkeypatch, tmp_path):
|
||||
# A DATA_DIR under the user's home must come out as ~/… in the report.
|
||||
import os
|
||||
home_dir = os.path.join(os.path.expanduser("~"), ".omnivoice-test-probe")
|
||||
monkeypatch.setattr(diagnose, "DATA_DIR", home_dir)
|
||||
check = diagnose._check_disk()
|
||||
assert os.path.expanduser("~") not in check["detail"]
|
||||
|
||||
|
||||
def test_format_text_ascii_and_exit_signal(report):
|
||||
text = format_text(report)
|
||||
# ASCII-only: Windows consoles on legacy code pages must not choke.
|
||||
text.encode("ascii")
|
||||
assert "OmniVoice Studio self-check" in text
|
||||
assert ("looks healthy" in text) == report["summary"]["ok"]
|
||||
|
||||
|
||||
# ── Deep synthesis check (mocked — no real model load in CI) ─────────────
|
||||
|
||||
|
||||
def test_deep_off_by_default(report):
|
||||
assert "deep_synth" not in [c["id"] for c in report["checks"]]
|
||||
|
||||
|
||||
def test_deep_check_success(monkeypatch):
|
||||
class FakeBackend:
|
||||
sample_rate = 24000
|
||||
def generate(self, text, **kw):
|
||||
import torch
|
||||
return torch.zeros(1, 24000) # exactly 1s
|
||||
import services.tts_backend as tb
|
||||
monkeypatch.setattr(tb, "get_active_tts_backend", lambda model=None: FakeBackend())
|
||||
monkeypatch.setattr(tb, "active_backend_id", lambda: "fake")
|
||||
check = diagnose._check_deep_synthesis()
|
||||
assert check["status"] == OK
|
||||
assert "1.0s of audio" in check["detail"]
|
||||
|
||||
|
||||
def test_deep_check_engine_failure(monkeypatch):
|
||||
import services.tts_backend as tb
|
||||
def _boom(model=None):
|
||||
raise RuntimeError("weights corrupted at /home/eve/cache")
|
||||
monkeypatch.setattr(tb, "get_active_tts_backend", _boom)
|
||||
check = diagnose._check_deep_synthesis()
|
||||
assert check["status"] == FAIL
|
||||
assert "/home/eve" not in check["detail"] # scrubbed
|
||||
assert check["hint"]
|
||||
|
||||
|
||||
def test_deep_check_skips_during_model_load(monkeypatch):
|
||||
import services.model_manager as mm
|
||||
monkeypatch.setattr(mm, "get_model_status", lambda: {"status": "loading"})
|
||||
check = diagnose._check_deep_synthesis()
|
||||
assert check["status"] == WARN
|
||||
assert "skipped" in check["detail"]
|
||||
@@ -0,0 +1,68 @@
|
||||
"""core.diagnostic_bundle — the drag-onto-a-GitHub-issue zip."""
|
||||
import json
|
||||
import os
|
||||
import zipfile
|
||||
|
||||
import pytest
|
||||
|
||||
from core import diagnostic_bundle
|
||||
from core.diagnostic_bundle import build_bundle
|
||||
|
||||
EXPECTED_MEMBERS = {
|
||||
"meta.json",
|
||||
"self_check.txt",
|
||||
"self_check.json",
|
||||
"errors.json",
|
||||
"logs/omnivoice.log.txt",
|
||||
"logs/crash_log.txt",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def bundle_env(monkeypatch, tmp_path):
|
||||
"""Isolated log files + output dir, with a secret planted in the log."""
|
||||
log = tmp_path / "omnivoice.log"
|
||||
log.write_text(
|
||||
"2026-01-01 INFO startup ok\n"
|
||||
"2026-01-01 ERROR failed for /home/eve/voice.wav token=hf_"
|
||||
+ "Z" * 34
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
crash = tmp_path / "crash_log.txt"
|
||||
crash.write_text("--- ts ---\nTraceback from /Users/eve/app\n", encoding="utf-8")
|
||||
out = tmp_path / "outputs"
|
||||
monkeypatch.setattr(diagnostic_bundle, "LOG_PATH", str(log))
|
||||
monkeypatch.setattr(diagnostic_bundle, "CRASH_LOG_PATH", str(crash))
|
||||
monkeypatch.setattr(diagnostic_bundle, "OUTPUTS_DIR", str(out))
|
||||
return tmp_path
|
||||
|
||||
|
||||
def test_bundle_members_and_meta(bundle_env):
|
||||
path = build_bundle(include_network=False)
|
||||
assert os.path.exists(path)
|
||||
with zipfile.ZipFile(path) as zf:
|
||||
assert set(zf.namelist()) == EXPECTED_MEMBERS
|
||||
meta = json.loads(zf.read("meta.json"))
|
||||
assert meta["app_version"]
|
||||
report = json.loads(zf.read("self_check.json"))
|
||||
assert report["summary"]["passed"] >= 1
|
||||
|
||||
|
||||
def test_bundle_log_tails_are_scrubbed(bundle_env):
|
||||
path = build_bundle(include_network=False)
|
||||
with zipfile.ZipFile(path) as zf:
|
||||
log_tail = zf.read("logs/omnivoice.log.txt").decode()
|
||||
crash_tail = zf.read("logs/crash_log.txt").decode()
|
||||
assert "/home/eve" not in log_tail
|
||||
assert "hf_" + "Z" * 34 not in log_tail
|
||||
assert "***REDACTED***" in log_tail
|
||||
assert "/Users/eve" not in crash_tail
|
||||
|
||||
|
||||
def test_bundle_survives_missing_logs(bundle_env, monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(diagnostic_bundle, "LOG_PATH", str(tmp_path / "missing.log"))
|
||||
monkeypatch.setattr(diagnostic_bundle, "CRASH_LOG_PATH", str(tmp_path / "missing_crash.txt"))
|
||||
path = build_bundle(include_network=False)
|
||||
with zipfile.ZipFile(path) as zf:
|
||||
assert "(no file at" in zf.read("logs/omnivoice.log.txt").decode()
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Diarization must register torch safe-globals before loading (issue #270).
|
||||
|
||||
PyTorch 2.6+ defaults `torch.load` to `weights_only=True`, whose secure
|
||||
unpickler rejects the pyannote checkpoint's metadata globals
|
||||
(`torch_version.TorchVersion`, omegaconf nodes, …). `get_diarization_pipeline`
|
||||
must register the same allowlist the WhisperX VAD load uses, before calling
|
||||
`Pipeline.from_pretrained`, or diarization breaks even with the license
|
||||
accepted.
|
||||
"""
|
||||
import sys
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def reset_diar(monkeypatch):
|
||||
import services.model_manager as mm
|
||||
monkeypatch.setattr(mm, "_diar_pipeline", None, raising=False)
|
||||
yield mm
|
||||
monkeypatch.setattr(mm, "_diar_pipeline", None, raising=False)
|
||||
|
||||
|
||||
def test_loads_pyannote_after_registering_safe_globals(reset_diar, monkeypatch):
|
||||
mm = reset_diar
|
||||
order = []
|
||||
|
||||
# Token present (App source).
|
||||
monkeypatch.setattr(
|
||||
"services.token_resolver.resolve",
|
||||
lambda: types.SimpleNamespace(token="hf_test", source="app", user="u"),
|
||||
)
|
||||
|
||||
# Spy on the shared allowlister; must run BEFORE from_pretrained.
|
||||
from services import asr_backend as ab
|
||||
monkeypatch.setattr(
|
||||
ab.WhisperXBackend, "_allow_vad_pickle_globals",
|
||||
staticmethod(lambda: order.append("allow")),
|
||||
)
|
||||
|
||||
fake_pipe = object()
|
||||
|
||||
def _from_pretrained(*a, **k):
|
||||
order.append("load")
|
||||
return fake_pipe
|
||||
|
||||
fake_mod = types.ModuleType("pyannote.audio")
|
||||
fake_mod.Pipeline = types.SimpleNamespace(from_pretrained=_from_pretrained)
|
||||
monkeypatch.setitem(sys.modules, "pyannote.audio", fake_mod)
|
||||
|
||||
# CPU device → no .to() call on the fake pipe.
|
||||
monkeypatch.setattr(mm, "get_best_device", lambda: "cpu")
|
||||
|
||||
result = mm.get_diarization_pipeline()
|
||||
|
||||
assert result is fake_pipe
|
||||
assert order == ["allow", "load"], f"allowlist must precede load, got {order}"
|
||||
|
||||
|
||||
def test_no_token_short_circuits_without_loading(reset_diar, monkeypatch):
|
||||
mm = reset_diar
|
||||
monkeypatch.setattr("services.token_resolver.resolve", lambda: None)
|
||||
pipe, err = mm.get_diarization_pipeline(return_error=True)
|
||||
assert pipe is None
|
||||
assert err == mm.DIARIZATION_ERR_NO_TOKEN
|
||||
@@ -0,0 +1,93 @@
|
||||
"""core.error_journal — structured, deduped, classified backend error ring."""
|
||||
import json
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from core import error_journal
|
||||
from core.error_journal import classify_exception, record, recent
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolated_journal(monkeypatch, tmp_path):
|
||||
"""Point persistence at a temp file and start every test empty."""
|
||||
monkeypatch.setattr(error_journal, "JOURNAL_PATH", str(tmp_path / "journal.jsonl"))
|
||||
error_journal._entries.clear()
|
||||
yield
|
||||
error_journal._entries.clear()
|
||||
|
||||
|
||||
# ── Classification ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"exc,trace,expected",
|
||||
[
|
||||
(RuntimeError("CUDA out of memory. Tried to allocate 2.5 GiB"), "", "GPU_OOM"),
|
||||
(RuntimeError("MPS backend out of memory"), "", "GPU_OOM"),
|
||||
(OSError(28, "No space left on device"), "", "DISK_FULL"),
|
||||
(RuntimeError("401 Client Error: Unauthorized for url: https://huggingface.co/x"), "", "HF_AUTH_FAILED"),
|
||||
(RuntimeError("boom"), "huggingface_hub.errors.GatedRepoError: ...", "HF_AUTH_FAILED"),
|
||||
(FileNotFoundError("No such file or directory: 'ffmpeg'"), "", "FFMPEG_MISSING"),
|
||||
(ConnectionError("Connection refused"), "", "NETWORK_ERROR"),
|
||||
(TimeoutError("timed out"), "", "NETWORK_ERROR"),
|
||||
(ValueError("tensor shape mismatch"), "", "UNKNOWN"),
|
||||
],
|
||||
)
|
||||
def test_classify(exc, trace, expected):
|
||||
assert classify_exception(exc, trace) == expected
|
||||
|
||||
|
||||
def test_pyannote_needs_auth_marker():
|
||||
# pyannote alone is any diarization bug — must NOT classify as license.
|
||||
plain = RuntimeError("pyannote pipeline failed on segment 3")
|
||||
assert classify_exception(plain, "") != "PYANNOTE_LICENSE_REQUIRED"
|
||||
gated = RuntimeError("pyannote/speaker-diarization-3.1: 403 gated repo, accept access")
|
||||
assert classify_exception(gated, "") == "PYANNOTE_LICENSE_REQUIRED"
|
||||
|
||||
|
||||
# ── Recording + dedup ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_record_and_recent_order():
|
||||
record(ValueError("first"), route="/a")
|
||||
record(KeyError("second"), route="/b")
|
||||
errors = recent()
|
||||
assert errors[0]["message"].endswith("'second'") or "second" in errors[0]["message"]
|
||||
assert errors[1]["route"] == "/a"
|
||||
|
||||
|
||||
def test_dedup_bumps_count():
|
||||
for _ in range(3):
|
||||
record(ValueError("same failure"), route="/gen")
|
||||
errors = recent()
|
||||
assert len(errors) == 1
|
||||
assert errors[0]["count"] == 3
|
||||
|
||||
|
||||
def test_trace_is_scrubbed_and_truncated():
|
||||
trace = "File \"/home/eve/app/main.py\" line 1\n" + "x" * 10_000
|
||||
entry = record(RuntimeError("boom"), trace=trace)
|
||||
assert "/home/eve" not in entry["trace"]
|
||||
assert len(entry["trace"]) <= error_journal._MAX_TRACE_CHARS
|
||||
|
||||
|
||||
def test_ring_capped():
|
||||
for i in range(error_journal._MAX_ENTRIES + 10):
|
||||
record(ValueError(f"distinct-{i}"))
|
||||
assert len(recent(limit=50)) == error_journal._MAX_ENTRIES
|
||||
|
||||
|
||||
def test_persists_to_jsonl():
|
||||
record(ValueError("persisted"))
|
||||
with open(error_journal.JOURNAL_PATH, encoding="utf-8") as f:
|
||||
lines = [json.loads(line) for line in f]
|
||||
assert any("persisted" in e["message"] for e in lines)
|
||||
|
||||
|
||||
def test_record_never_raises(monkeypatch):
|
||||
# Even with persistence broken, record() must return an entry.
|
||||
monkeypatch.setattr(error_journal, "JOURNAL_PATH", "/nonexistent/dir/x.jsonl")
|
||||
entry = record(ValueError("still works"))
|
||||
assert entry["error_class"] == "UNKNOWN"
|
||||
assert entry["count"] == 1
|
||||
@@ -0,0 +1,55 @@
|
||||
"""`require_loopback` gate contract (issue #261).
|
||||
|
||||
The gate must stay strict on the desktop build (non-loopback → 403, which is the
|
||||
PR #81 trust boundary), but become a no-op in the headless Docker server mode,
|
||||
where Docker's NAT makes the loopback origin unenforceable and exposure is
|
||||
governed by the port mapping + the share PIN instead.
|
||||
"""
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from api.dependencies import require_loopback
|
||||
|
||||
|
||||
def _req(host):
|
||||
"""Minimal stand-in for a Starlette Request — the gate only reads client.host."""
|
||||
return SimpleNamespace(client=SimpleNamespace(host=host) if host else None)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_server_mode(monkeypatch):
|
||||
# Start each test from the desktop default regardless of the ambient env.
|
||||
monkeypatch.delenv("OMNIVOICE_SERVER_MODE", raising=False)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("host", ["127.0.0.1", "::1", "localhost"])
|
||||
def test_loopback_always_allowed(host):
|
||||
require_loopback(_req(host)) # must not raise
|
||||
|
||||
|
||||
def test_non_loopback_rejected_by_default():
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
require_loopback(_req("172.17.0.1")) # Docker bridge gateway
|
||||
assert exc.value.status_code == 403
|
||||
assert "loopback" in str(exc.value.detail).lower()
|
||||
|
||||
|
||||
def test_missing_client_rejected_by_default():
|
||||
with pytest.raises(HTTPException):
|
||||
require_loopback(_req(None))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("val", ["1", "true", "TRUE", "yes", "on"])
|
||||
def test_server_mode_allows_non_loopback(monkeypatch, val):
|
||||
monkeypatch.setenv("OMNIVOICE_SERVER_MODE", val)
|
||||
require_loopback(_req("172.17.0.1")) # must not raise
|
||||
require_loopback(_req("127.0.0.1")) # loopback still fine
|
||||
|
||||
|
||||
@pytest.mark.parametrize("val", ["0", "false", "no", "", "off"])
|
||||
def test_falsey_server_mode_keeps_gate_strict(monkeypatch, val):
|
||||
monkeypatch.setenv("OMNIVOICE_SERVER_MODE", val)
|
||||
with pytest.raises(HTTPException):
|
||||
require_loopback(_req("10.0.0.5"))
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Inline `[pause Nms]` transcript marker (issue #276).
|
||||
|
||||
Covers the pure text parser (`parse_pause_markers`) and the model-free audio
|
||||
stitching (`_render_with_pauses`) — no TTS model is loaded; `gen_span` is a
|
||||
fake that returns known-length tensors so the silence math is deterministic.
|
||||
"""
|
||||
import torch
|
||||
|
||||
from omnivoice.utils.text import (
|
||||
parse_pause_markers,
|
||||
PAUSE_DEFAULT_MS,
|
||||
PAUSE_MAX_MS,
|
||||
)
|
||||
from api.routers.generation import _render_with_pauses
|
||||
|
||||
|
||||
# ── parser ────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_no_marker_returns_text_unchanged():
|
||||
assert parse_pause_markers("Hello world") == [("Hello world", 0)]
|
||||
assert parse_pause_markers("") == [("", 0)]
|
||||
|
||||
|
||||
def test_bare_pause_uses_default():
|
||||
assert parse_pause_markers("a[pause]b") == [("a", PAUSE_DEFAULT_MS), ("b", 0)]
|
||||
|
||||
|
||||
def test_explicit_ms_and_seconds():
|
||||
assert parse_pause_markers("a [pause 500ms] b") == [("a ", 500), (" b", 0)]
|
||||
assert parse_pause_markers("a[pause 1s]b") == [("a", 1000), ("b", 0)]
|
||||
assert parse_pause_markers("a[pause 1.5s]b") == [("a", 1500), ("b", 0)]
|
||||
|
||||
|
||||
def test_bare_number_is_milliseconds():
|
||||
assert parse_pause_markers("a[pause 250]b") == [("a", 250), ("b", 0)]
|
||||
|
||||
|
||||
def test_case_insensitive_and_inner_whitespace():
|
||||
assert parse_pause_markers("a[PAUSE 750 ms]b") == [("a", 750), ("b", 0)]
|
||||
|
||||
|
||||
def test_leading_marker_yields_empty_first_span():
|
||||
assert parse_pause_markers("[pause 1s]Hi") == [("", 1000), ("Hi", 0)]
|
||||
|
||||
|
||||
def test_trailing_marker():
|
||||
assert parse_pause_markers("Bye[pause]") == [("Bye", PAUSE_DEFAULT_MS)]
|
||||
|
||||
|
||||
def test_adjacent_markers_sum():
|
||||
assert parse_pause_markers("a[pause][pause 2s]b") == [
|
||||
("a", PAUSE_DEFAULT_MS + 2000),
|
||||
("b", 0),
|
||||
]
|
||||
|
||||
|
||||
def test_duration_clamped():
|
||||
assert parse_pause_markers("a[pause 99s]b") == [("a", PAUSE_MAX_MS), ("b", 0)]
|
||||
|
||||
|
||||
def test_text_round_trips_without_markers():
|
||||
text = "One [pause 200ms] two [pause] three"
|
||||
spans = "".join(t for t, _ in parse_pause_markers(text))
|
||||
assert spans == "One two three"
|
||||
|
||||
|
||||
# ── audio stitching ─────────────────────────────────────────────────────────
|
||||
|
||||
def _fake_gen(sr):
|
||||
# Each span renders to 1 second of mono audio (shape [1, sr]); the value
|
||||
# encodes nothing — we only assert lengths.
|
||||
return lambda text: torch.ones(1, sr)
|
||||
|
||||
|
||||
def test_render_inserts_silence_between_spans():
|
||||
sr = 1000 # 1000 samples/sec keeps the math trivial
|
||||
segs = [("hello", 500), ("world", 0)] # 500ms = 500 samples of silence
|
||||
out = _render_with_pauses(_fake_gen(sr), segs, sr)
|
||||
# 1s audio + 0.5s silence + 1s audio = 2.5s = 2500 samples
|
||||
assert out.shape == (1, 2500)
|
||||
# The middle 500 samples (after the first second) are silence.
|
||||
assert torch.all(out[:, sr:sr + 500] == 0)
|
||||
assert torch.all(out[:, :sr] == 1)
|
||||
|
||||
|
||||
def test_render_leading_silence():
|
||||
sr = 1000
|
||||
segs = [("", 1000), ("hi", 0)] # 1s leading silence + 1s audio
|
||||
out = _render_with_pauses(_fake_gen(sr), segs, sr)
|
||||
assert out.shape == (1, 2000)
|
||||
assert torch.all(out[:, :1000] == 0)
|
||||
assert torch.all(out[:, 1000:] == 1)
|
||||
|
||||
|
||||
def test_render_pause_only_input_is_silence():
|
||||
sr = 1000
|
||||
segs = [("", 750)] # only a pause, no speakable text
|
||||
out = _render_with_pauses(_fake_gen(sr), segs, sr)
|
||||
assert out.numel() == 750
|
||||
assert torch.all(out == 0)
|
||||
|
||||
|
||||
def test_render_no_pause_single_span_passthrough():
|
||||
sr = 1000
|
||||
segs = [("just text", 0)]
|
||||
out = _render_with_pauses(_fake_gen(sr), segs, sr)
|
||||
assert out.shape == (1, sr)
|
||||
@@ -0,0 +1,83 @@
|
||||
"""PyTorch-Whisper backend must work as a standalone fallback (issue #255).
|
||||
|
||||
On machines where WhisperX / faster-whisper can't load cuDNN 8
|
||||
(`cudnn_ops_infer64_8.dll` missing), the PyTorch-Whisper backend should build
|
||||
its own transformers pipeline on demand — without OMNIVOICE_PRELOAD_TTS_ASR=1
|
||||
and without loading the full TTS model.
|
||||
"""
|
||||
import sys
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
from services import asr_backend as ab
|
||||
|
||||
|
||||
def test_is_available_when_transformers_present():
|
||||
ok, msg = ab.PyTorchWhisperBackend.is_available()
|
||||
assert ok is True
|
||||
assert msg == "ready"
|
||||
|
||||
|
||||
def test_reuses_constructor_pipe_without_building(monkeypatch):
|
||||
sentinel = object()
|
||||
be = ab.PyTorchWhisperBackend(asr_pipe=sentinel)
|
||||
|
||||
def _boom(*a, **k):
|
||||
raise AssertionError("must not build a pipeline when one was passed in")
|
||||
|
||||
# transformers.pipeline is imported lazily inside _ensure_pipe.
|
||||
fake_tf = types.ModuleType("transformers")
|
||||
fake_tf.pipeline = _boom
|
||||
monkeypatch.setitem(sys.modules, "transformers", fake_tf)
|
||||
|
||||
be._ensure_pipe()
|
||||
assert be._pipe is sentinel
|
||||
|
||||
|
||||
def test_lazy_builds_standalone_pipeline(monkeypatch):
|
||||
"""No preloaded pipe → build a standalone transformers ASR pipeline, with no
|
||||
call into the TTS model loader (get_model)."""
|
||||
captured = {}
|
||||
|
||||
def fake_pipeline(task, **kw):
|
||||
captured["task"] = task
|
||||
captured["kw"] = kw
|
||||
return lambda *a, **k: {"chunks": []}
|
||||
|
||||
fake_tf = types.ModuleType("transformers")
|
||||
fake_tf.pipeline = fake_pipeline
|
||||
monkeypatch.setitem(sys.modules, "transformers", fake_tf)
|
||||
monkeypatch.setattr("services.model_manager.get_best_device", lambda: "cpu")
|
||||
|
||||
# Guard: building the standalone pipe must NOT pull in the full TTS model.
|
||||
import services.model_manager as mm
|
||||
|
||||
def _no_get_model(*a, **k):
|
||||
raise AssertionError("standalone ASR build must not call get_model()")
|
||||
|
||||
monkeypatch.setattr(mm, "get_model", _no_get_model, raising=False)
|
||||
|
||||
be = ab.PyTorchWhisperBackend(asr_pipe=None)
|
||||
be._ensure_pipe()
|
||||
|
||||
assert be._pipe is not None
|
||||
assert captured["task"] == "automatic-speech-recognition"
|
||||
assert captured["kw"]["model"] # a concrete model name was chosen
|
||||
|
||||
|
||||
def test_pytorch_asr_model_overridable_via_env(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_pipeline(task, **kw):
|
||||
captured["kw"] = kw
|
||||
return object()
|
||||
|
||||
fake_tf = types.ModuleType("transformers")
|
||||
fake_tf.pipeline = fake_pipeline
|
||||
monkeypatch.setitem(sys.modules, "transformers", fake_tf)
|
||||
monkeypatch.setattr("services.model_manager.get_best_device", lambda: "cpu")
|
||||
monkeypatch.setenv("OMNIVOICE_PYTORCH_ASR_MODEL", "openai/whisper-small")
|
||||
|
||||
ab.PyTorchWhisperBackend(asr_pipe=None)._ensure_pipe()
|
||||
assert captured["kw"]["model"] == "openai/whisper-small"
|
||||
@@ -37,6 +37,10 @@ def test_system_info_smoke(client):
|
||||
# running version from here so it shows the real version, not a dash (#249).
|
||||
from core.version import APP_VERSION
|
||||
assert body["app_version"] == APP_VERSION
|
||||
# Settings → About → Architecture must reflect the SERVER's machine, not the
|
||||
# client browser's navigator.platform (which showed "Win32" in Docker, #262).
|
||||
import platform as _pf
|
||||
assert body["arch"] == _pf.machine()
|
||||
|
||||
|
||||
def test_health_exposes_version(client):
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
"""core.scrub — privacy scrubber for diagnostic/bug-report text.
|
||||
|
||||
The scrubber is the last gate before text can reach a prefilled GitHub
|
||||
Issues URL, so these tests pin the exact redaction behavior per platform
|
||||
path style and per credential shape.
|
||||
"""
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from core.scrub import scrub_text, REDACTED
|
||||
|
||||
|
||||
# ── Home directory redaction ──────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw,expected",
|
||||
[
|
||||
("/Users/alice/Library/Logs/app.log", "~/Library/Logs/app.log"),
|
||||
("/home/bob/.omnivoice/omnivoice.log", "~/.omnivoice/omnivoice.log"),
|
||||
(r"C:\Users\carol\AppData\Roaming\OmniVoice", r"~\AppData\Roaming\OmniVoice"),
|
||||
(r"D:\Users\dave\models", r"~\models"),
|
||||
],
|
||||
)
|
||||
def test_home_paths_redacted(raw, expected):
|
||||
assert scrub_text(raw) == expected
|
||||
|
||||
|
||||
def test_actual_process_home_redacted():
|
||||
home = os.path.expanduser("~")
|
||||
assert home not in scrub_text(f"failed to open {home}/some/file.wav")
|
||||
|
||||
|
||||
def test_home_redaction_inside_traceback():
|
||||
tb = (
|
||||
'Traceback (most recent call last):\n'
|
||||
' File "/home/eve/OmniVoice/backend/main.py", line 42, in synth\n'
|
||||
"FileNotFoundError: /Users/eve/voice.wav not found"
|
||||
)
|
||||
out = scrub_text(tb)
|
||||
assert "/home/eve" not in out
|
||||
assert "/Users/eve" not in out
|
||||
assert 'File "~/OmniVoice/backend/main.py"' in out
|
||||
|
||||
|
||||
# ── Credential-shaped substrings ──────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"secret",
|
||||
[
|
||||
"hf_" + "A" * 34, # HuggingFace token
|
||||
"ghp_" + "B" * 36, # GitHub classic PAT
|
||||
"github_pat_" + "C" * 22, # GitHub fine-grained PAT
|
||||
"sk-" + "d" * 40, # OpenAI-style key
|
||||
],
|
||||
)
|
||||
def test_tokens_redacted(secret):
|
||||
out = scrub_text(f"auth failed with token={secret} (401)")
|
||||
assert secret not in out
|
||||
assert REDACTED in out
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"benign",
|
||||
["hf_hub", "hf_pipeline_load", "sk-learn", "ghp_x"],
|
||||
)
|
||||
def test_short_identifiers_survive(benign):
|
||||
# Identifiers shorter than real-token length must NOT be clobbered —
|
||||
# they're exactly what makes a stack trace debuggable.
|
||||
assert benign in scrub_text(f"import error in {benign} module")
|
||||
|
||||
|
||||
# ── Env-var secret values ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_env_secret_value_redacted(monkeypatch):
|
||||
monkeypatch.setenv("TRANSLATE_API_KEY", "super-secret-value-123")
|
||||
out = scrub_text("request failed: api_key=super-secret-value-123 rejected")
|
||||
assert "super-secret-value-123" not in out
|
||||
assert REDACTED in out
|
||||
|
||||
|
||||
def test_env_secret_short_value_not_swept(monkeypatch):
|
||||
# A short value would shred unrelated text (every "yes" in the report).
|
||||
monkeypatch.setenv("SOME_PASSWORD", "yes")
|
||||
assert scrub_text("yes, the export worked") == "yes, the export worked"
|
||||
|
||||
|
||||
def test_env_non_secret_name_untouched(monkeypatch):
|
||||
monkeypatch.setenv("OMNIVOICE_MODEL", "k2-fsa/OmniVoice")
|
||||
assert "k2-fsa/OmniVoice" in scrub_text("loading k2-fsa/OmniVoice")
|
||||
|
||||
|
||||
# ── Robustness ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_none_and_empty():
|
||||
assert scrub_text(None) == ""
|
||||
assert scrub_text("") == ""
|
||||
|
||||
|
||||
def test_non_string_coerced():
|
||||
assert scrub_text(42) == "42"
|
||||
Reference in New Issue
Block a user