Update TTS providers and story markup

This commit is contained in:
2026-05-20 22:13:31 +02:00
parent b911c40d89
commit 8258ea2321
36 changed files with 1482 additions and 197 deletions
+30 -2
View File
@@ -42,6 +42,34 @@ The conductor points toward Eibenreith.
The bracket value is the visible term to find. The parenthesized value is the note shown on hover/focus. The renderer marks every matching instance of the term in the same right-page block. The tag is not displayed and is not sent to TTS. Avoid raw Ink control characters in the explanation; `|`, `{`, and `}` must be escaped in Ink as `\|`, `\{`, and `\}` if they are needed literally. The bracket value is the visible term to find. The parenthesized value is the note shown on hover/focus. The renderer marks every matching instance of the term in the same right-page block. The tag is not displayed and is not sent to TTS. Avoid raw Ink control characters in the explanation; `|`, `{`, and `}` must be escaped in Ink as `\|`, `\{`, and `\}` if they are needed literally.
## TTS Reading Instructions
TTS instruction tags are story tags scoped to the paragraph/block they belong to. They are not rendered, and they are only sent to TTS providers that support per-request reading instructions. Currently this means OpenAI with `gpt-4o-mini-tts`.
```ink
„Ich habe nichts gesehen“, sagt Viktor.
#tts[Read softly, with controlled unease.]
```
The default form omits a provider and is the preferred authoring style. Providers that support instructions may consume it; providers that do not support instructions silently ignore it. Provider-specific instructions are only needed when two providers should receive different direction, or when an instruction must be hidden from all but one provider. They use the tag parameter position:
```ink
„Ich habe nichts gesehen“, sagt Viktor.
#tts[openai](Read softly, with controlled unease.)
```
The shorthand `#tts-openai[...]` is also accepted. `#tts(...)` is equivalent to providerless `#tts[...]` if parentheses read better in a local context. `tts-1` and `tts-1-hd` ignore these instructions because the OpenAI speech endpoint only supports the `instructions` request parameter for `gpt-4o-mini-tts`.
Keep instructions short and describe performance rather than content. OpenAI's TTS guide recommends using `gpt-4o-mini-tts` when you need controllable delivery; useful instruction targets include tone, emotional range, intonation, speaking speed, accent, impressions, and whispering. Good examples:
```ink
#tts[Speak with restrained concern and a slower pace.]
#tts[Whisper the line with controlled urgency.]
#tts-openai[Use a dry, formal tone; avoid melodrama.]
```
Avoid repeating the full dialogue in the instruction. Put the words to be spoken in the story text, and use `#tts` only to describe how the provider should read that block.
## Choice Metadata ## Choice Metadata
Choice tags are placed on the Ink choice they belong to: Choice tags are placed on the Ink choice they belong to:
@@ -56,9 +84,9 @@ Implemented choice metadata:
- `#key:x`: reserves keyboard key `X` for the choice. - `#key:x`: reserves keyboard key `X` for the choice.
- `#letter[x]`: older equivalent for reserving keyboard key `X`. - `#letter[x]`: older equivalent for reserving keyboard key `X`.
- `#action:group` or `#action[group]`: stores a category/template hint. - `#action:group` or `#action[group]`: assigns the choice to an invisible action group.
The current UI renders all choices in one list. Explicit keys are assigned first; choices without explicit keys receive `1` through `0`, then `A` through `Z` in visible order while skipping explicit keys. `#optional` choices are displayed italic. Grouping columns, stable shuffling, `#gated[...]`, and `#sort[...]` are documented authoring conventions or future metadata, not fully implemented UI behavior yet. The current UI renders all choices in one visible list. Choices are first grouped by `#action` in the order each new action group appears in the authored choice list. Choices inside each group are randomized. Choices without `#action` form one final unlabelled group shown after all tagged groups. Explicit keys are assigned before automatic keys; choices without explicit keys receive `1` through `0`, then `A` through `Z` in final visible order while skipping explicit keys. `#optional` choices are displayed italic. Grouping columns, `#gated[...]`, and `#sort[...]` are documented authoring conventions or future metadata, not fully implemented UI behavior yet.
## Popup And End-State Tags ## Popup And End-State Tags
+13 -2
View File
@@ -61,7 +61,7 @@ Environment variables are loaded from `.env`.
- `OPENROUTER_API_KEY`: API key for LLM command interpretation. - `OPENROUTER_API_KEY`: API key for LLM command interpretation.
- `OPENROUTER_MODEL`: OpenRouter model name. - `OPENROUTER_MODEL`: OpenRouter model name.
TTS provider settings are configured in the browser options menu and persisted in browser storage. Providers currently include `none`, browser speech synthesis, Kokoro, ElevenLabs, and OpenAI. Production should not assume a universal TTS default; the game or player state selects the active mode, and `none` is the safe fallback. TTS provider settings are configured in the browser options menu and persisted in browser storage. Providers currently include `none`, browser speech synthesis, Kokoro, ElevenLabs, OpenAI, and local OpenAI-compatible servers. Production should not assume a universal TTS default; the game or player state selects the active mode, and `none` is the safe fallback.
## Starting A Game ## Starting A Game
@@ -116,6 +116,17 @@ The train stops at Eibenreith.
Glossary markup is a normal story tag scoped to the paragraph/block it is attached to. The UI finds every matching visible instance of the term in that right-page block and adds a hover/focus note. The tag itself is not displayed, is not sent to TTS, and is ignored by choices and command history. Avoid raw Ink control characters in the explanation; `|`, `{`, and `}` must be escaped in Ink as `\|`, `\{`, and `\}` if they are needed literally. Glossary markup is a normal story tag scoped to the paragraph/block it is attached to. The UI finds every matching visible instance of the term in that right-page block and adds a hover/focus note. The tag itself is not displayed, is not sent to TTS, and is ignored by choices and command history. Avoid raw Ink control characters in the explanation; `|`, `{`, and `}` must be escaped in Ink as `\|`, `\{`, and `\}` if they are needed literally.
TTS reading instructions:
```text
„Ich habe nichts gesehen“, sagt Viktor.
#tts[Read softly, with controlled unease.]
```
`#tts[...]` is scoped to the paragraph/block it is attached to and is sent only to providers that support per-request reading instructions. This providerless form is the normal authoring style; `#tts(...)` is equivalent if parentheses read better. Provider-specific forms are also accepted for overrides, for example `#tts[openai](Read softly.)` or `#tts-openai[Read softly.]`. Currently only OpenAI `gpt-4o-mini-tts` consumes the instruction.
Write TTS instructions as concise performance direction: tone, emotion, intonation, pace, accent, or whispering/singing style. Keep the spoken words in the paragraph itself and use the tag only to guide delivery.
Canonical block/media/control tags use Ink-style `#` syntax. In Ink these are real Ink tags. In YAML and Z-code narrative output, leading `#...` lines are parsed by the server into the same structured `StoryTag` objects before reaching the client. The browser only consumes structured `TurnResult` objects. Canonical block/media/control tags use Ink-style `#` syntax. In Ink these are real Ink tags. In YAML and Z-code narrative output, leading `#...` lines are parsed by the server into the same structured `StoryTag` objects before reaching the client. The browser only consumes structured `TurnResult` objects.
Tag format: Tag format:
@@ -127,7 +138,7 @@ Tag format:
#key:value #key:value
``` ```
For Ink choices, put choice-local tags under the choice they belong to. Explicit keyboard letters are supported with `# letter[x]`, `#letter[x]`, or the colon form `#key:x`; the client reserves those keys first, then assigns the remaining visible choices from `1` through `0`, then `A` through `Z` in order. `#optional` renders the choice in italic. `# action[name]` or `#action:name` is parsed as a category/template hint for future choice layouts, although the current UI displays all choices in one list. For Ink choices, put choice-local tags under the choice they belong to. Explicit keyboard letters are supported with `# letter[x]`, `#letter[x]`, or the colon form `#key:x`; the client reserves those keys first, then assigns the remaining visible choices from `1` through `0`, then `A` through `Z` in visible order. `#optional` renders the choice in italic. `# action[name]` or `#action:name` assigns an invisible action group: group order follows the first appearance of each action tag in the authored list, entries inside each group are randomized, and choices without an action tag are grouped last.
Chapter: Chapter:
+7 -1
View File
@@ -209,6 +209,9 @@ Supported story tags include:
- `#sfx[file](max=8 fade fade-duration=2)` - `#sfx[file](max=8 fade fade-duration=2)`
- `#music[file](crossfade loop lead=4)` - `#music[file](crossfade loop lead=4)`
- `#gloss[term](definition)` - `#gloss[term](definition)`
- `#tts[instruction]`
- `#tts(instruction)`
- `#tts[provider](instruction)` / `#tts-openai[instruction]`
- `#score[...]` - `#score[...]`
- `#error[...]` - `#error[...]`
- `#achievement[...]` - `#achievement[...]`
@@ -222,6 +225,9 @@ Choice tags:
- `#action[name]` - `#action[name]`
The active choice UI is one list. Explicit keys are reserved first, then remaining choices receive `1` through `0`, then `A` through `Z`. The active choice UI is one list. Explicit keys are reserved first, then remaining choices receive `1` through `0`, then `A` through `Z`.
Before key assignment, choices are ordered by invisible `#action` groups. The first appearance of each action group in the authored list determines group order. Choices inside each group are randomized for presentation. Choices without an action group form one final group shown last. Group labels are not displayed.
TTS instruction tags are paragraph/block metadata. They are ignored by renderers and by providers that do not support per-request reading instructions. Providerless `#tts[...]` and `#tts(...)` are the default authoring forms; provider-specific forms are optional filters for provider overrides. OpenAI consumes matching instructions only for `gpt-4o-mini-tts`, where they are sent as the Speech API `instructions` field. Instructions should describe delivery, such as tone, emotion, intonation, pace, accent, whispering, humming, or singing style.
Markdown emphasis: Markdown emphasis:
@@ -233,7 +239,7 @@ Markdown emphasis:
## Audio, TTS, And Media ## Audio, TTS, And Media
TTS providers currently include `none`, Browser Speech, Kokoro, ElevenLabs, and OpenAI. Provider modules exist, but Browser Speech and Kokoro need focused validation before being considered production-ready. TTS providers currently include `none`, Browser Speech, Kokoro, ElevenLabs, OpenAI, and local OpenAI-compatible servers. Provider modules exist, but Browser Speech and Kokoro need focused validation before being considered production-ready.
TTS cache keys include provider, voice, provider speed value, language, and exact normalized TTS string. Fast-forward must accelerate visible animation and fade/stop active TTS without cancelling background generations unless the foreground block has been waiting long enough. TTS cache keys include provider, voice, provider speed value, language, and exact normalized TTS string. Fast-forward must accelerate visible animation and fade/stop active TTS without cancelling background generations unless the foreground block has been waiting long enough.
+2 -2
View File
@@ -22,7 +22,7 @@ This is the active implementation checklist. Architecture lives in `SPECIFICATIO
- [x] Right-page `#gloss[term](definition)` hover/focus notes. - [x] Right-page `#gloss[term](definition)` hover/focus notes.
- [x] Image rendering for landscape, square, and portrait cases, with history/save restoration. - [x] Image rendering for landscape, square, and portrait cases, with history/save restoration.
- [x] Sound effect and music playback, including music lead-in, loop/once, and ducking. - [x] Sound effect and music playback, including music lead-in, loop/once, and ducking.
- [x] TTS `none`, OpenAI, ElevenLabs, Browser Speech, and Kokoro provider modules. - [x] TTS `none`, OpenAI, local OpenAI-compatible, ElevenLabs, Browser Speech, and Kokoro provider modules.
- [x] TTS cache keys include provider, voice, speed, language, and exact normalized string. - [x] TTS cache keys include provider, voice, speed, language, and exact normalized string.
- [x] Persisted speech enable state, provider, voice, speed, language, and volume preferences. - [x] Persisted speech enable state, provider, voice, speed, language, and volume preferences.
- [x] Fast-forward for text animation and active TTS fade/stop. - [x] Fast-forward for text animation and active TTS fade/stop.
@@ -48,7 +48,7 @@ This is the active implementation checklist. Architecture lives in `SPECIFICATIO
- [ ] Get Browser TTS working reliably. - [ ] Get Browser TTS working reliably.
- [ ] Get Kokoro.js TTS working for English-language games. - [ ] Get Kokoro.js TTS working for English-language games.
- [ ] Get Kokoro.js TTS working for German-language games. - [ ] Get Kokoro.js TTS working for German-language games.
- [ ] Add a TTS module for self-hosted or local OpenAI-compatible servers. - [x] Add a TTS module for self-hosted or local OpenAI-compatible servers.
- [ ] Test every documented `#tag` parameter and effect against parser, server, client rendering, playback, and save/load behavior. - [ ] Test every documented `#tag` parameter and effect against parser, server, client rendering, playback, and save/load behavior.
- [ ] Remove local file paths and diff-comments from third-party license markdown, refresh included third-party licenses/material, update external libraries where possible, and move any local modifications into our code. - [ ] Remove local file paths and diff-comments from third-party license markdown, refresh included third-party licenses/material, update external libraries where possible, and move any local modifications into our code.
- [ ] Improve credits page layout with more window height, a larger notices markdown pane, and a Hollywood-style title scroll for creative credits. - [ ] Improve credits page layout with more window height, a larger notices markdown pane, and a Hollywood-style title scroll for creative credits.
+95 -77
View File
@@ -1,19 +1,19 @@
// eibenreith_01_zug.ink // eibenreith_01_zug.ink
// Kapitel: Die Reise / Zugabteil. // Kapitel: Das Abteil.
// Enthält Charaktergenerator, Abteil-Weave, Viktor-Beobachtung und Missionsbriefing. // Enthält Charaktergenerator, Abteil-Weave, Viktor-Beobachtung und Missionsbriefing.
=== intro_train === === intro_train ===
Der Zug lässt Wien hinter sich, doch Wien gibt dich noch nicht frei. #chapter[Die Reise] #music[Kaiserpunk Waltz.mp3](crossfade, loop, lead=8) #chapter[Das Abteil] #music[Kaiserpunk Waltz.mp3](crossfade, loop, lead=8)
Es hängt noch am schwarzen Glanz deiner Reisestiefel, am Schnitt deines Mantels, am engen kleinen Gefängnis deiner Handschuhe. Es liegt im Siegel des Schreibens, das in deinem Ridikül ruht, im Geruch von Kohlenrauch, der sich selbst in die Polster der ersten Klasse geschlichen hat, und in der Tatsache, dass Herr Viktor Nowak dir gegenübersitzt, als wäre dieses Abteil kein mit Samt, Messing und poliertem Holz ausgekleideter Reiseraum, sondern ein provisorisches Amtszimmer auf Rädern. #gloss[Ridikül](Kleine Damenhandtasche oder Handarbeitsbeutel des achtzehnten und neunzehnten Jahrhunderts.) #gloss[erste Klasse](Vornehmste Wagenklasse der Eisenbahn mit besserer Polsterung mehr Raum und höherem Fahrpreis.) #gloss[Abteil](Abgeschlossener Raum eines Eisenbahnwagens.) #gloss[Amtszimmer](Zimmer einer Behörde oder Kanzlei in dem Amtsgeschäfte geführt werden.) Der Zug lässt Wien hinter sich, doch Wien hat dich noch nicht freigegeben. Es bleibt an dir haften, im Puder am Kragen, im Kohlenrauch im Haar, im Druck des Korsetts unter dem Reisekleid und im leisen Schwitzen unter den Handschuhen. Im Ridikül ruht das versiegelte Schreiben des Hofes; in den Polstern der ersten Klasse ruht der Geruch fremder Reisen. Herr Viktor Nowak sitzt dir gegenüber, unbewegt, korrekt, aufmerksam. Kein Blick von ihm ist unhöflich. Keiner ist unschuldig. Das Abteil fährt nach Süden, doch für einen Augenblick scheint es weniger ein Reiseraum als ein gepolstertes Amtszimmer, in dem selbst dein Atem Haltung annehmen muss. #tts[Begin in a low, intimate narrative voice. Keep the pace unhurried and formal, with a thread of contained unease.] #gloss[Ridikül](Kleine Damenhandtasche oder Handarbeitsbeutel. Klein genug um harmlos zu wirken, groß genug für Briefe, Schlüssel und jene Dinge, von denen Herren später behaupten, sie hätten nichts davon gewusst.) #gloss[erste Klasse](Die teuerste Wagenklasse der Eisenbahn. Sie bietet weichere Polster, bessere Luft und vor allem weniger Menschen, was im bürgerlichen Fortschrittsglauben oft dasselbe bedeutet.) #gloss[Korsett](Formendes Mieder für Taille und Oberkörper. Es stützt Haltung, beschränkt Atem und beweist, dass weibliche Selbstbeherrschung zuerst am Körper verlangt wird.) #gloss[Amtszimmer](Zimmer der amtlichen Ordnung. Dort wird ein Mensch selten lauter unterworfen, als wenn alles ganz höflich und schriftlich geschieht.)
-> train_compartment -> train_compartment
=== train_compartment === === train_compartment ===
{not tut_choice_intro: {not tut_choice_intro:
#alert[Jede Wahl beginnt mit einem hervorgehobenen Verb. Es nennt die Handlung, die du in diesem Augenblick ausführst: schauen, untersuchen, greifen, fragen, antworten oder warten. Einige vertraute Abenteuerbefehle besitzen feste Tasten, etwa L für Schaue und X für Untersuche.] #alert[Links erscheinen deine Entscheidungen. Du kannst sie mit der Maus wählen oder die angezeigte Taste drücken. Das hervorgehobene Wort nennt die Handlung. Einige Kürzel folgen der alten Textadventure-Gewohnheit: L für Schaue, X für Untersuche.]
~ tut_choice_intro = true ~ tut_choice_intro = true
} }
@@ -45,19 +45,19 @@ Es hängt noch am schwarzen Glanz deiner Reisestiefel, am Schnitt deines Mantels
=== next_compartment_definition === === next_compartment_definition ===
{not define_class_and_name: {not define_class_and_name:
Was immer du im Abteil betrachtest, führt auf dieselbe unausgesprochene Frage zurück: Aus welcher Ordnung kommst du, dass Wien dich nun in eine andere schicken kann? #gloss[Abteil](Abgeschlossener Raum eines Eisenbahnwagens.) Was immer du im Abteil betrachtest, führt auf dieselbe unausgesprochene Frage zurück: Aus welcher Ordnung kommst du, dass Wien dich nun in eine andere schicken kann?
-> define_class_and_name -> -> define_class_and_name ->
->-> ->->
} }
{not define_religion_and_supernatural: {not define_religion_and_supernatural:
Die Fahrt nach Hohenreith ist kein bloßer Auftrag. Sie berührt Kirche, Aberglauben, Spiritismus und die Frage, welche unsichtbaren Dinge du überhaupt für möglich hältst. #gloss[Hohenreith](Gräfliches Jagdhaus oberhalb von Eibenreith und neuerer Name des Zielortes.) #gloss[Spiritismus](Lehre und Praxis des Verkehrs mit Geistern oder Verstorbenen.) Die Fahrt nach Hohenreith ist kein bloßer Auftrag. Sie berührt Kirche, Aberglauben, Spiritismus und die Frage, welche unsichtbaren Dinge du überhaupt für möglich hältst.
-> define_religion_and_supernatural -> -> define_religion_and_supernatural ->
->-> ->->
} }
{not define_appearance: {not define_appearance:
Der Zug fährt in einen Tunnel. Das Land draußen verschwindet; die Scheibe verliert die Berge, die Felder, den Himmel, und behält nur noch das Abteil. #gloss[Abteil](Abgeschlossener Raum eines Eisenbahnwagens.) Der Zug fährt in einen Tunnel. Das Land draußen verschwindet; die Scheibe verliert die Berge, die Felder, den Himmel, und behält nur noch das Abteil. #tts[Drop the volume slightly and slow down. Make the tunnel feel like the room closing around the listener.]
-> define_appearance -> -> define_appearance ->
->-> ->->
} }
@@ -66,29 +66,29 @@ Es hängt noch am schwarzen Glanz deiner Reisestiefel, am Schnitt deines Mantels
=== compartment_room === === compartment_room ===
Das Abteil bleibt, was es war: Polster, Messing, Glas, Bedienung, Abstand. Ein fahrender kleiner Salon, zusammengesetzt aus Dingen, die Menschen benutzen, ohne sie selbst zu putzen, zu polieren oder zu bezahlen. #gloss[Abteil](Abgeschlossener Raum eines Eisenbahnwagens.) #gloss[Salon](Vornehmer Empfangsraum oder gesellschaftlicher Raum.) Das Abteil bleibt, was es war: Polster, Messing, Glas, Bedienung, Abstand. Ein fahrender kleiner Salon, zusammengesetzt aus Dingen, die Menschen benutzen, ohne sie selbst zu putzen, zu polieren oder zu bezahlen. #gloss[Salon](Ein Raum, in dem Gesellschaft sich selbst für Geist hält. Wer dort spricht, spricht selten nur mit der Person vor sich.)
->-> ->->
=== compartment_letter === === compartment_letter ===
Deine Hand findet das Ridikül. Unter Stoff, Verschluss und Handschuh liegt das Schreiben des Hofes, schwerer durch Bedeutung als durch Papier. #gloss[Ridikül](Kleine Damenhandtasche oder Handarbeitsbeutel des achtzehnten und neunzehnten Jahrhunderts.) Deine Hand findet das Ridikül. Unter Stoff, Verschluss und Handschuh liegt das Schreiben des Hofes, schwerer durch Bedeutung als durch Papier. #tts[Read more quietly here, as if the letter has physical weight. Put a slight pause before "schwerer".] #gloss[Ridikül](Kleine Damenhandtasche oder Handarbeitsbeutel. Klein genug um harmlos zu wirken, groß genug für Briefe, Schlüssel und jene Dinge, von denen Herren später behaupten, sie hätten nichts davon gewusst.)
{define_religion_and_supernatural: {define_religion_and_supernatural:
Du kennst seinen Ton nun, seine Auslassungen, seine Vorsicht und die Art, wie eine Kanzlei das Unheimliche in anständige Grammatik kleidet. #gloss[Kanzlei](Amtliche Schreib und Verwaltungsstelle für Schreiben Erlässe und Akten.) Du kennst seinen Ton nun, seine Auslassungen, seine Vorsicht und die Art, wie eine Kanzlei das Unheimliche in anständige Grammatik kleidet. #gloss[Kanzlei](Amtliche Schreibstube. Sie verwandelt Furcht in Formulierungen, Schuld in Zuständigkeit und Menschen in Aktenlagen.)
} }
->-> ->->
=== look_out_window === === look_out_window ===
Draußen zerfallen die letzten Ränder der Stadt in winterbraune Felder und Dörfer, deren Kirchtürme gegen den Pfiff der Lokomotive nichts auszurichten haben. Die Schienen nehmen sich das Land, ohne um Erlaubnis zu fragen. Dämme schneiden durch Obstgärten. Telegraphenstangen gleiten in regelmäßigen Abständen vorbei, eine nach der anderen, wie Gedanken, die man zu rasch verworfen hat. #gloss[Telegraphenstangen](Holzstangen für Leitungen des elektrischen Telegraphen.) #sfx[steam-whistle.ogg] Draußen zerfallen die letzten Ränder der Stadt in winterbraune Felder und Dörfer, deren Kirchtürme gegen den Pfiff der Lokomotive nichts auszurichten haben. Die Schienen nehmen sich das Land, ohne um Erlaubnis zu fragen. Dämme schneiden durch Obstgärten. Telegraphenstangen gleiten in regelmäßigen Abständen vorbei, eine nach der anderen, wie Gedanken, die man zu rasch verworfen hat. #tts[Let the rhythm lightly suggest train motion without becoming sing-song. Build a little momentum through the list of passing objects.] #gloss[Telegraphenstangen](Holzstangen der elektrischen Nachrichten. Die Monarchie liebt Drähte, weil sie Gerüchte schneller machen und zugleich wie Ordnung aussehen.) #sfx[steam-whistle.ogg]
Du erwartest, dass sich die Eisenbahn wie ein Sieg des Jahrhunderts anfühlt. Du erwartest, dass sich die Eisenbahn wie ein Sieg des Jahrhunderts anfühlt.
Stattdessen fühlt sie sich wie ein Streit an. #image[suedbahn.png](landscape) Stattdessen fühlt sie sich wie ein Streit an. #image[train_cabin.png](landscape)
Die Maschine wirft sich mit einer Gewalt nach Süden, die gute Gesellschaft niemals offen bewundert hätte. Die Lampen zittern in ihren Fassungen. Deine Tasse schlägt leise gegen die Untertasse. Jenseits der Scheibe beginnt das Land zu steigen, zuerst beinahe höflich, dann mit festerem Willen, bis die Bahnlinie selbst mit den Bergen zu verhandeln scheint: durch Steinbögen, schwarze Tunnel und Viadukte, die mit dem ganzen Selbstvertrauen kaiserlicher Ingenieurskunst über Schluchten gesetzt sind. #gloss[Viadukte](Große Brückenbauwerke über Täler und Schluchten.) Die Maschine wirft sich mit einer Gewalt nach Süden, die gute Gesellschaft niemals offen bewundert hätte. Die Lampen zittern in ihren Fassungen. Deine Tasse schlägt leise gegen die Untertasse. Jenseits der Scheibe beginnt das Land zu steigen, zuerst beinahe höflich, dann mit festerem Willen, bis die Bahnlinie selbst mit den Bergen zu verhandeln scheint: durch Steinbögen, schwarze Tunnel und Viadukte, die mit dem ganzen Selbstvertrauen kaiserlicher Ingenieurskunst über Schluchten gesetzt sind. #tts[Give this paragraph more force and breath than the previous one. Make the machinery feel powerful, then widen into awe at the viaducts.] #gloss[Viadukte](Große Brücken der Eisenbahn über Täler und Schluchten. Ingenieurskunst hat den schönen Vorteil, dass sie Abgründe nicht leugnet, sondern überquert.)
->-> ->->
@@ -96,28 +96,28 @@ Die Maschine wirft sich mit einer Gewalt nach Süden, die gute Gesellschaft niem
Viktor wirkt noch immer von nichts beeindruckt. Viktor wirkt noch immer von nichts beeindruckt.
Seine Zivilkleidung ist korrekt genug, um keinen Widerspruch hervorzurufen: dunkler Gehrock, nüchterne Weste, Handschuhe, tadelloser Kragen, dazu die Haltung eines Mannes, der selbst im Sitzen nie ganz aufhört, im Dienst zu sein. Doch kein Schneider der Monarchie kann Disziplin verbergen. #gloss[Zivilkleidung](Bürgerliche Kleidung im Gegensatz zur Uniform.) #gloss[Gehrock](Vornehmer Herrenrock mit langen Schößen.) Seine Zivilkleidung ist korrekt genug, um keinen Widerspruch hervorzurufen: dunkler Gehrock, nüchterne Weste, Handschuhe, tadelloser Kragen, dazu die Haltung eines Mannes, der selbst im Sitzen nie ganz aufhört, im Dienst zu sein. Doch kein Schneider der Monarchie kann Disziplin verbergen. #tts[Use a precise, observant tone. Let the clothing list feel clipped and controlled, then sharpen the final sentence.] #gloss[Zivilkleidung](Bürgerliche Kleidung im Gegensatz zur Uniform. Manche Männer legen damit nur den Stoff ab, nicht den Befehlston.) #gloss[Gehrock](Langer Herrenrock für korrekte Tageskleidung. Ein Kleidungsstück, das Männern erlaubt, bürgerlich, amtlich und moralisch zugleich auszusehen.)
* [__Untersuche__: Viktors Haltung.] #action:orientation * [__Untersuche__: Viktors Haltung.] #action:orientation
Sie bleibt in seinen Schultern, in der Sparsamkeit seiner Bewegungen, in der Art, wie er selbst im Sitzen nie ganz aufhört, einen Raum zu sichern. Sie bleibt in seinen Schultern, in der Sparsamkeit seiner Bewegungen, in der Art, wie er selbst im Sitzen nie ganz aufhört, einen Raum zu sichern.
* [__Schaue__: Viktors Blick nach.] #action:orientation * [__Schaue__: Viktors Blick nach.] #action:orientation
Seine Augen messen Türen, Fenster, Gepäcknetz, Korridor, dein Gesicht und wieder die Tür. Nicht gierig. Nicht unhöflich. Nur vollständig. #gloss[Gepäcknetz](Netz oder Ablage im Eisenbahnabteil für kleinere Gepäckstücke.) #gloss[Korridor](Seitengang oder Verbindungsgang eines Wagens oder Gebäudes.) Seine Augen messen Türen, Fenster, Gepäcknetz, Korridor, dein Gesicht und wieder die Tür. Nicht gierig. Nicht unhöflich. Nur vollständig. #gloss[Gepäcknetz](Ablage über den Sitzen des Eisenbahnabteils. Dort reisen Hüte, Schachteln und kleine Lügen, die zu leicht für den Koffer sind.)
* [__Untersuche__: Viktors Kleidung.] #action:orientation * [__Untersuche__: Viktors Kleidung.] #action:orientation
Auf dem Papier ist er dein Sekretär und Reisebegleiter. #gloss[Sekretär](Schreib und Vertrauensbeamter oder privater Gehilfe.) Auf dem Papier ist er dein Sekretär und Reisebegleiter. #gloss[Sekretär](Schreib und Vertrauensgehilfe. Ein Mann dieses Namens ordnet Papier, Termine und manchmal auch die Wahrheit, bis sie in ein vorzeigbares Format passt.)
In Wahrheit ist er ein Offizier, den man einer heiklen Angelegenheit beigegeben hat; aus Kanälen, die Namen haben, aber sie nicht unnötig gebrauchen. Sein wirklicher Rang bleibt im Jagdhaus Hohenreith ungenannt: Rittmeister Viktor Alois Nowak. #gloss[Jagdhaus](Ländliches Haus oder kleineres Schloss für Jagdaufenthalte.) #gloss[Hohenreith](Gräfliches Jagdhaus oberhalb von Eibenreith und neuerer Name des Zielortes.) #gloss[Rittmeister](Offiziersrang der Kavallerie ungefähr dem Hauptmann entsprechend.) In Wahrheit ist er ein Offizier, den man einer heiklen Angelegenheit beigegeben hat; aus Kanälen, die Namen haben, aber sie nicht unnötig gebrauchen. Sein wirklicher Rang bleibt im Jagdhaus Hohenreith ungenannt: Rittmeister Viktor Alois Nowak. #gloss[Jagdhaus](Adeliger Landsitz für Jagden und kurze Aufenthalte. Weniger Hauptsitz als Bühne für Gäste, Förster, Gewehre und Geheimnisse.) #gloss[Rittmeister](Kavallerieoffizier im Rang eines Hauptmanns. Ein solcher Mann kann Zivilkleidung tragen, aber nur selten Zivilist werden.)
- -
Die Menschen, zu denen ihr reist, haben nicht nach einer Ermittlerin verlangt. Nicht offiziell. Die Kabinettskanzlei hat dich geschickt. Das Militär hat ihn geschickt, damit aus dir kein Skandal wird, ehe du nützlich werden kannst. #gloss[Kabinettskanzlei](Hof und Staatsstelle für unmittelbare Schreiben Eingaben und Weisungen im Umkreis des Monarchen.) Die Menschen, zu denen ihr reist, haben nicht nach einer Ermittlerin verlangt. Nicht offiziell. Die Kabinettskanzlei hat dich geschickt. Das Militär hat ihn geschickt, damit aus dir kein Skandal wird, ehe du nützlich werden kannst. #gloss[Kabinettskanzlei](Stelle nahe am Monarchen, wo Bitten, Berichte und Befehle in jene Sprache gebracht werden, in der Macht wie Papier aussieht.)
->-> ->->
=== define_class_and_name === === define_class_and_name ===
{not tut_character_intro: {not tut_character_intro:
#alert[Manche Entscheidungen legen fest, wer du bist. Herkunft, Glaube, Fähigkeiten, Aussehen und Auftreten können später Türen öffnen oder schließen.] #alert[Einige Entscheidungen beschreiben nicht nur, was du tust, sondern wer du bist. Herkunft, Glaube, Auftreten und Ruf werden dadurch festgelegt und können später wieder Bedeutung gewinnen.]
~ tut_character_intro = true ~ tut_character_intro = true
} }
@@ -127,15 +127,15 @@ Die Menschen, zu denen ihr reist, haben nicht nach einer Ermittlerin verlangt. N
~ class_confidence += 2 ~ class_confidence += 2
~ court_loyalty += 1 ~ court_loyalty += 1
Nicht der Luxus beunruhigt dich. Luxus ist nur Holz, Stoff, Messing, Bedienung, Stille. Entscheidend ist, ob die Diener zweimal hinsehen, ob der Schaffner die Stimme senkt, ob ein anderer Reisender deine Handschuhe prüft und beschließt, nicht nach deinem Auftrag zu fragen. #gloss[Schaffner](Eisenbahnbediensteter für Reisende Fahrkarten und Ordnung im Zug.) Nicht der Luxus beunruhigt dich. Luxus ist nur Holz, Stoff, Messing, Bedienung, Stille. Entscheidend ist, ob die Diener zweimal hinsehen, ob der Schaffner die Stimme senkt, ob ein anderer Reisender deine Handschuhe prüft und beschließt, nicht nach deinem Auftrag zu fragen.
Du wurdest unter Menschen geboren, die solche Dinge früher verstanden als Freundlichkeit. Du wurdest unter Menschen geboren, die solche Dinge früher verstanden als Freundlichkeit.
Du hast früh gelernt, dass jedes Zimmer einen Hof enthält, auch wenn kein Kaiser anwesend ist. Ein Mädchen deines Ranges wird darin unterrichtet, einzutreten, sich zu verneigen, vorgestellt, platziert und wieder vergessen zu werden; nur genug zu sprechen, mehr zu verstehen, als es zugibt, und zu wissen, dass ein Familienname zugleich Schlüssel und Kette sein kann. Du hast früh gelernt, dass jedes Zimmer einen Hof enthält, auch wenn kein Kaiser anwesend ist. Ein Mädchen deines Ranges wird darin unterrichtet, einzutreten, sich zu verneigen, vorgestellt, platziert und wieder vergessen zu werden; nur genug zu sprechen, mehr zu verstehen, als es zugibt, und zu wissen, dass ein Familienname zugleich Schlüssel und Kette sein kann.
Deine eigene Familie besitzt keinen großen Sitz, keine Schar von Verwaltern, kein altes Recht, Provinzen zu befehlen. Doch dein Name öffnete Türen in Wiener Salons, und sobald du in diesen Zimmern warst, lerntest du, Menschen Geschichten wiederholen zu lassen, die sie nur hatten andeuten wollen. #gloss[Verwaltern](Beauftragte für Güter Einkünfte Personal und wirtschaftliche Angelegenheiten.) #gloss[Salons](Gesellschaftliche Empfangsräume und Zusammenkünfte.) Deine eigene Familie besitzt keinen großen Sitz, keine Schar von Verwaltern, kein altes Recht, Provinzen zu befehlen. Doch dein Name öffnete Türen in Wiener Salons, und sobald du in diesen Zimmern warst, lerntest du, Menschen Geschichten wiederholen zu lassen, die sie nur hatten andeuten wollen. #gloss[Verwaltern](Männer, die Besitz in Ordnung halten. Ein Gut ohne Verwalter ist Adel als Romantik, ein Gut mit Verwaltern ist Adel als Rechnung.) #gloss[Salons](Gesellschaftliche Räume für Geist, Klavier, Ruf und Eheanbahnung. Man tritt ein, um zu sprechen, und wird oft erst beim Schweigen beurteilt.)
Dein Ruf als Medium ist nicht vom Himmel gefallen. Er wurde zusammengesetzt aus Halblicht, richtigen Vermutungen, sorgsamen Pausen und der Bereitschaft besser geborener Toren, Aufführung für Offenbarung zu halten. #gloss[Medium](Person der man Verkehr mit Geistern oder verborgenen Kräften zuschreibt.) Dein Ruf als Medium ist nicht vom Himmel gefallen. Er wurde zusammengesetzt aus Halblicht, richtigen Vermutungen, sorgsamen Pausen und der Bereitschaft besser geborener Toren, Aufführung für Offenbarung zu halten. #gloss[Medium](Person, durch die Geister oder verborgene Kräfte sprechen sollen. Gesellschaftlich besonders brauchbar, weil eine Frau so Dinge sagen darf, die man ihr als eigene Meinung übel nähme.)
Bevor der Hof dich benutzen konnte, musste die Gesellschaft dich erst erfinden. Bevor der Hof dich benutzen konnte, musste die Gesellschaft dich erst erfinden.
@@ -171,9 +171,9 @@ Die Menschen, zu denen ihr reist, haben nicht nach einer Ermittlerin verlangt. N
Das war dein erster Vorteil. Das war dein erster Vorteil.
Eine Dienstmagd weiß, welche Tür wichtig ist, weil sie die anderen benutzt. Eine Näherin lernt Körper, weil sie sie misst. Eine Zofe lernt Geheimnisse, weil feine Leute ihre Seelen wie Handschuhe liegen lassen, gewiss, dass niemand unter ihnen Hände hat. #gloss[Dienstmagd](Weibliche Hausbedienstete für niedere Arbeiten im Haushalt.) #gloss[Näherin](Frau die berufsmäßig Kleidungsstücke näht oder ausbessert.) #gloss[Zofe](Persönliche weibliche Dienerin einer Dame.) Eine Dienstmagd weiß, welche Tür wichtig ist, weil sie die anderen benutzt. Eine Näherin lernt Körper, weil sie sie misst. Eine Zofe lernt Geheimnisse, weil feine Leute ihre Seelen wie Handschuhe liegen lassen, gewiss, dass niemand unter ihnen Hände hat. #gloss[Dienstmagd](Weibliche Hausbedienstete für niedere Arbeiten. Niedrig heißt dabei nur die Stellung, nicht die Menge dessen, was sie sieht.) #gloss[Näherin](Frau, die Kleidung näht, ändert und ausbessert. Wer Körper ausmisst, lernt mehr über Stand und Eitelkeit, als ein Schneider je zugeben muss.) #gloss[Zofe](Persönliche Dienerin einer Dame. Zuständig für Haar, Kleidung, kleine Notfälle und jene Wahrheiten, die eine Gesellschaft nur deshalb nicht kennt, weil Dienerinnen selten gefragt werden.)
Du stiegst auf durch Begabung, Protektion, Nachahmung, Nervenstärke und die furchtbare Bequemlichkeit, für harmlos gehalten zu werden. Als Wien zu flüstern begann, du sähest mehr als anständige Leute sehen, hattest du schon Jahre damit verbracht, das zu sehen, was anständige Leute übersahen. #gloss[Protektion](Förderung durch Schutz oder Empfehlung einer höherstehenden Person.) Du stiegst auf durch Begabung, Protektion, Nachahmung, Nervenstärke und die furchtbare Bequemlichkeit, für harmlos gehalten zu werden. Als Wien zu flüstern begann, du sähest mehr als anständige Leute sehen, hattest du schon Jahre damit verbracht, das zu sehen, was anständige Leute übersahen. #gloss[Protektion](Förderung durch eine höherstehende Person. Eine verwerfliche Begünstigung, sobald sie anderen nützt, und eine notwendige Verbindung, sobald sie einem selbst nützt.)
-> choose_name_working -> choose_name_working
@@ -210,9 +210,9 @@ Wien kannte dich unter dem Namen, den die Gesellschaft brauchbar gemacht hatte.
=== choose_surname_noble === === choose_surname_noble ===
Dein Titel ist durch Geburt und durch die vorsichtige Bescheidenheit deiner Familie bestimmt: keine Gräfin, keine Fürstin, keiner jener glänzenden Namen, die Botschafter und Gläubiger wie Staub anziehen. #gloss[Gräfin](Frau gräflichen Ranges.) #gloss[Fürstin](Frau fürstlichen Ranges.) Dein Titel ist durch Geburt und durch die vorsichtige Bescheidenheit deiner Familie bestimmt: keine Gräfin, keine Fürstin, keiner jener glänzenden Namen, die Botschafter und Gläubiger wie Staub anziehen. #gloss[Gräfin](Frau gräflichen Ranges. Der Titel öffnet Türen, aber auch Erwartungen, Schulden, Blicke und Verwandtschaften.) #gloss[Fürstin](Frau fürstlichen Ranges. So hoch, dass selbst Fehler zunächst als Eigenart erscheinen dürfen.)
Eine Freiin. Freiherrlicher Rang. Brauchbar. Zugelassen, aber nicht thronend. #gloss[Freiin](Unverheiratete Tochter eines Freiherrn oder Dame freiherrlichen Ranges.) Eine Freiin. Freiherrlicher Rang. Brauchbar. Zugelassen, aber nicht thronend. #gloss[Freiin](Unverheiratete Dame freiherrlichen Ranges. Hoch genug, um angekündigt zu werden, niedrig genug, um von höheren Häusern übersehen werden zu dürfen.)
* [__Führe den Namen__: Freiin von Rauhenfels] #action:thinking * [__Führe den Namen__: Freiin von Rauhenfels] #action:thinking
~ title_part = "Freiin von" ~ title_part = "Freiin von"
@@ -268,7 +268,7 @@ Die Salons, die zuerst über dich lachten und dich dann wieder einluden, lernten
=== choose_surname_middle === === choose_surname_middle ===
Dein Familienname enthält kein Partikel, das den Aufstieg abfedert. Er muss allein aufrecht stehen. #gloss[Partikel](Adelspartikel im Namen wie von oder zu.) Dein Familienname enthält kein Partikel, das den Aufstieg abfedert. Er muss allein aufrecht stehen. #gloss[Partikel](Das kleine von oder zu im Namen. Ein winziges Wort, das so viel Gewicht tragen darf, wie andere Menschen mit Arbeit füllen müssen.)
* [__Führe den Namen__: Leitner] #action:thinking * [__Führe den Namen__: Leitner] #action:thinking
~ title_part = "Fräulein" ~ title_part = "Fräulein"
@@ -362,9 +362,9 @@ Ein einfacher Name kann in Wien eine Last sein. Er sagt den Leuten, wie wenig Ac
=== assemble_full_name === === assemble_full_name ===
{birth_class == "noble": {birth_class == "noble":
Auf Visitenkarten, in Briefen, in den vorsichtigen Mündern der Dienerschaft bist du {given_names} {title_part} {surname}. #gloss[Visitenkarten](Gedruckte Besuchskarten mit Namen und Titel.) #gloss[Dienerschaft](Gesamtheit der Bediensteten eines Hauses oder Gutes.) Auf Visitenkarten, in Briefen, in den vorsichtigen Mündern der Dienerschaft bist du {given_names} {title_part} {surname}. #gloss[Visitenkarten](Gedruckte Besuchskarten mit Namen und Titel. Eine Dame gibt damit nicht nur ihre Anwesenheit ab, sondern eine kleine, korrekt geschnittene Behauptung.) #gloss[Dienerschaft](Die Bediensteten eines Hauses. Offiziell Teil der Ordnung, praktisch ihr Gedächtnis.)
- else: - else:
In Bahndokumenten, Hotelbüchern und auf den Zungen von Menschen, die noch nicht entschieden haben, wie viel Achtung du verdienst, bist du {title_part} {given_names} {surname}. #gloss[Bahndokumenten](Schriftstücke der Eisenbahn wie Fahrkarten oder Gepäckscheine.) #gloss[Hotelbüchern](Gästebücher oder Meldebücher eines Gasthauses oder Hotels.) In Bahndokumenten, Hotelbüchern und auf den Zungen von Menschen, die noch nicht entschieden haben, wie viel Achtung du verdienst, bist du {title_part} {given_names} {surname}. #gloss[Bahndokumenten](Papiere der Eisenbahn. Sie beweisen, wohin man fahren darf, was man mitführt und dass selbst Bewegung ein Formular verlangt.) #gloss[Hotelbüchern](Gästebücher der Herbergen und Hotels. Wer reist, hinterlässt darin nicht nur einen Namen, sondern eine Spur für Wirte, Polizei und Neugier.)
} }
Aber in der privaten Kammer, in der ein Name zuerst beantwortet wird, ehe er gespielt werden muss, bist du {common_name}. Aber in der privaten Kammer, in der ein Name zuerst beantwortet wird, ehe er gespielt werden muss, bist du {common_name}.
@@ -373,21 +373,21 @@ Aber in der privaten Kammer, in der ein Name zuerst beantwortet wird, ehe er ges
=== define_religion_and_supernatural === === define_religion_and_supernatural ===
Du berührst das Ridikül, ohne es sofort zu öffnen. #gloss[Ridikül](Kleine Damenhandtasche oder Handarbeitsbeutel des achtzehnten und neunzehnten Jahrhunderts.) Du berührst das Ridikül, ohne es sofort zu öffnen. #gloss[Ridikül](Kleine Damenhandtasche oder Handarbeitsbeutel. Klein genug um harmlos zu wirken, groß genug für Briefe, Schlüssel und jene Dinge, von denen Herren später behaupten, sie hätten nichts davon gewusst.)
Das Schreiben darin nennt dich nicht Ermittlerin. Es nennt dich, in einer Prosa trocken genug, um durch beliebig viele Ämter zu gelangen, eine Frau, deren ungewöhnlicher spiritistischer Ruf sie für eine heikle Haushaltsangelegenheit empfehle. Die Formulierung ist erlesen. Sie bejaht nicht und verneint nicht. Sie erlaubt allen Beteiligten, später zu glauben, sie hätten nichts Ungehöriges geglaubt. #gloss[spiritistischer](Den Spiritismus betreffend.) Das Schreiben darin nennt dich nicht Ermittlerin. Es nennt dich, in einer Prosa trocken genug, um durch beliebig viele Ämter zu gelangen, eine Frau, deren ungewöhnlicher spiritistischer Ruf sie für eine heikle Haushaltsangelegenheit empfehle. Die Formulierung ist erlesen. Sie bejaht nicht und verneint nicht. Sie erlaubt allen Beteiligten, später zu glauben, sie hätten nichts Ungehöriges geglaubt. #gloss[spiritistischer Ruf](Ein Ruf im Umkreis des Spiritismus. Sehr nützlich, wenn eine Frau gehört werden soll, aber nicht zu deutlich als Urheberin ihrer eigenen Einsichten erscheinen darf.)
Die gräfliche Familie im Jagdhaus Hohenreith hat um Diskretion ersucht. Wien hat mit einem versiegelten Schreiben geantwortet, mit einer Frau, der man nachsagt, sie spreche mit dem Verborgenen, und mit einem Mann ihr gegenüber, der eigene Befehle hat. #gloss[Jagdhaus](Ländliches Haus oder kleineres Schloss für Jagdaufenthalte.) #gloss[Hohenreith](Gräfliches Jagdhaus oberhalb von Eibenreith und neuerer Name des Zielortes.) Die gräfliche Familie im Jagdhaus Hohenreith hat um Diskretion ersucht. Wien hat mit einem versiegelten Schreiben geantwortet, mit einer Frau, der man nachsagt, sie spreche mit dem Verborgenen, und mit einem Mann ihr gegenüber, der eigene Befehle hat. #gloss[Jagdhaus](Adeliger Landsitz für Jagden und kurze Aufenthalte. Weniger Hauptsitz als Bühne für Gäste, Förster, Gewehre und Geheimnisse.)
Das Schreiben nennt keine Kirche. Gerade das macht die Kirche anwesend. Das Schreiben nennt keine Kirche. Gerade das macht die Kirche anwesend.
* [__Glaube__: Der Glaube ist dir wirklich heilig.] #action:thinking * [__Glaube__: Der Glaube ist dir wirklich heilig.] #action:thinking
~ religion_stance = "devout_catholic" ~ religion_stance = "devout_catholic"
Gott ist kein Gesprächsgegenstand für Abteile. Er ist kein Talent, kein Ruf, keine gesellschaftliche Bequemlichkeit. Du glaubst nicht kindlich, aber tief: an Sünde, Gnade, Sakrament, Versuchung und an die gefährliche Nähe der unsichtbaren Welt. #gloss[Abteile](Abgeschlossene Räume eines Eisenbahnwagens.) #gloss[Sünde](Verstoß gegen göttliches Gebot oder sittliche Ordnung.) #gloss[Gnade](Göttliche Barmherzigkeit und Hilfe.) #gloss[Sakrament](Heilige kirchliche Handlung die göttliche Gnade vermittelt.) Gott ist kein Gesprächsgegenstand für Abteile. Er ist kein Talent, kein Ruf, keine gesellschaftliche Bequemlichkeit. Du glaubst nicht kindlich, aber tief: an Sünde, Gnade, Sakrament, Versuchung und an die gefährliche Nähe der unsichtbaren Welt.
* [__Bedenke__: Die katholische Ordnung, in der du erzogen wurdest.] #action:thinking * [__Bedenke__: Die katholische Ordnung, in der du erzogen wurdest.] #action:thinking
~ religion_stance = "social_catholic" ~ religion_stance = "social_catholic"
Du kennst die Feste, die Gebete, das Gewicht der Beichte und die Macht eines Pfarrers über Menschen, die behaupten, ihn nicht zu fürchten. Dein Glaube ist nicht leer; aber er ist ebenso Gewohnheit wie Überzeugung, ebenso Ordnung wie Trost. #gloss[Beichte](Katholisches Sakrament des Bekenntnisses und der Vergebung von Sünden.) #gloss[Pfarrer](Priester und Vorsteher einer Pfarre.) Du kennst die Feste, die Gebete, das Gewicht der Beichte und die Macht eines Pfarrers über Menschen, die behaupten, ihn nicht zu fürchten. Dein Glaube ist nicht leer; aber er ist ebenso Gewohnheit wie Überzeugung, ebenso Ordnung wie Trost.
* [__Misstraue__: Dem Weihrauch, hinter dem Behörden sitzen.] #action:thinking * [__Misstraue__: Dem Weihrauch, hinter dem Behörden sitzen.] #action:thinking
~ religion_stance = "josephinian_sceptic" ~ religion_stance = "josephinian_sceptic"
@@ -395,7 +395,7 @@ Das Schreiben nennt keine Kirche. Gerade das macht die Kirche anwesend.
* [__Verbinde__: Heiligenbilder, Totenmessen und Séancen.] #action:thinking * [__Verbinde__: Heiligenbilder, Totenmessen und Séancen.] #action:thinking
~ religion_stance = "spiritist_syncretic" ~ religion_stance = "spiritist_syncretic"
Heiligenbilder, Totenmessen, Séancen, Ahnungen, Tischklopfen, Träume: Die sauberen Grenzen dazwischen scheinen dir eher von Männern gezogen als von der Ewigkeit selbst. Was überlebt, spricht vielleicht in Formen, die keine Kanzlei genehmigt hat. #gloss[Heiligenbilder](Andachtsbilder oder Darstellungen heiliger Personen.) #gloss[Totenmessen](Katholische Messen für Verstorbene.) #gloss[Séancen](Spiritistische Sitzungen zum Verkehr mit Verstorbenen oder Geistern.) #gloss[Tischklopfen](Spiritistische Klopfzeichen eines Tisches als vermeintliche Botschaften.) #gloss[Kanzlei](Amtliche Schreib und Verwaltungsstelle für Schreiben Erlässe und Akten.) Heiligenbilder, Totenmessen, Séancen, Ahnungen, Tischklopfen, Träume: Die sauberen Grenzen dazwischen scheinen dir eher von Männern gezogen als von der Ewigkeit selbst. Was überlebt, spricht vielleicht in Formen, die keine Kanzlei genehmigt hat. #gloss[Heiligenbilder](Bilder heiliger Personen für Andacht, Trost und häusliche Überwachung des Gewissens. Ein guter Blick von der Wand erspart manchen schlechten Gedanken.) #gloss[Totenmessen](Messen für Verstorbene. Die Kirche versteht den Umgang mit Toten besser als die Salons, doch nicht immer mit weniger Interesse.) #gloss[Séancen](Geistersitzungen mit Tisch, gedämpftem Licht und sehr viel Erwartung. Je dunkler der Raum, desto leichter glauben die Anwesenden, sie sähen klar.) #gloss[Tischklopfen](Klopfzeichen, die als Botschaften aus der unsichtbaren Welt gelten. Eine bequeme Methode, wenn Tote sprechen sollen, ohne die Gesprächsordnung der Lebenden zu stören.) #gloss[Kanzlei](Amtliche Schreibstube. Sie verwandelt Furcht in Formulierungen, Schuld in Zuständigkeit und Menschen in Aktenlagen.)
* [__Erinnere dich__: Der Glaube hat dich geformt, bevor du dich wehren konntest.] #action:thinking * [__Erinnere dich__: Der Glaube hat dich geformt, bevor du dich wehren konntest.] #action:thinking
~ religion_stance = "wounded_catholic" ~ religion_stance = "wounded_catholic"
@@ -419,7 +419,7 @@ Vor dieser Reise, vor diesem Zug, bevor die Berge beginnen, Stück für Stück d
- religion_stance == "devout_catholic": - religion_stance == "devout_catholic":
Gerade deshalb erschreckt dich der Gedanke. Wer die Toten ruft, lädt vielleicht nicht nur die Toten ein. Gerade deshalb erschreckt dich der Gedanke. Wer die Toten ruft, lädt vielleicht nicht nur die Toten ein.
- religion_stance == "spiritist_syncretic": - religion_stance == "spiritist_syncretic":
Der Satz fügt sich in dir nicht gegen den Glauben, sondern unter seine Ränder, dort, wo Volksfrömmigkeit, Séance und Totenmesse einander längst heimlich berühren. #gloss[Volksfrömmigkeit](Religiöse Bräuche und Vorstellungen des Volkes neben der offiziellen Lehre.) #gloss[Séance](Spiritistische Sitzung zum Verkehr mit Verstorbenen oder Geistern.) #gloss[Totenmesse](Katholische Messe für Verstorbene.) Der Satz fügt sich in dir nicht gegen den Glauben, sondern unter seine Ränder, dort, wo Volksfrömmigkeit, Séance und Totenmesse einander längst heimlich berühren. #gloss[Volksfrömmigkeit](Frömmigkeit des Alltags. Sie hält sich an Kirche, Küche, Wetter, Krankheit und Erzählungen und fragt selten, ob eine Grenze amtlich gezogen wurde.) #gloss[Séance](Geistersitzung mit Tisch, gedämpftem Licht und sehr viel Erwartung. Je dunkler der Raum, desto leichter glauben die Anwesenden, sie sähen klar.) #gloss[Totenmesse](Messe für einen Verstorbenen. Ein kirchlicher Versuch, Trauer in Ordnung zu bringen und Schuld wenigstens liturgisch zu beschäftigen.)
- religion_stance == "josephinian_sceptic": - religion_stance == "josephinian_sceptic":
Du nennst es nicht Frömmigkeit. Eher eine vorläufige Hypothese über Dinge, für die die amtliche Sprache zu grob ist. Du nennst es nicht Frömmigkeit. Eher eine vorläufige Hypothese über Dinge, für die die amtliche Sprache zu grob ist.
- else: - else:
@@ -456,7 +456,7 @@ Vor dieser Reise, vor diesem Zug, bevor die Berge beginnen, Stück für Stück d
{ {
- religion_stance == "devout_catholic": - religion_stance == "devout_catholic":
Irgendwo in dir notiert eine strengere Stimme das Wort Sünde. Eine andere, praktischere, antwortet: Auftrag. #gloss[Sünde](Verstoß gegen göttliches Gebot oder sittliche Ordnung.) Irgendwo in dir notiert eine strengere Stimme das Wort Sünde. Eine andere, praktischere, antwortet: Auftrag.
- religion_stance == "wounded_catholic": - religion_stance == "wounded_catholic":
Wenn der Glaube dich schon als Mädchen verkleidete, ist es nur gerecht, dass du nun lernst, Verkleidungen selbst zu wählen. Wenn der Glaube dich schon als Mädchen verkleidete, ist es nur gerecht, dass du nun lernst, Verkleidungen selbst zu wählen.
- else: - else:
@@ -493,7 +493,7 @@ Unter Ruf und Aufführung hat die Erinnerung ihre eigene Aussage.
~ supernatural_senses = "genuine" ~ supernatural_senses = "genuine"
~ supernatural_exposure += 2 ~ supernatural_exposure += 2
Einmal, als Kind, wusstest du es, bevor das Telegramm kam. Einmal, in einem überfüllten Zimmer, trat der Kummer einer Fremden mit solcher Gewalt in dich ein, dass deine eigenen Knie nachgaben. Einmal sahst du in einem Spiegel eine Tür hinter dir, die nicht im Raum war, als du dich umdrehtest. #gloss[Telegramm](Durch Telegraphie übermittelte kurze schriftliche Nachricht.) Einmal, als Kind, wusstest du es, bevor das Telegramm kam. Einmal, in einem überfüllten Zimmer, trat der Kummer einer Fremden mit solcher Gewalt in dich ein, dass deine eigenen Knie nachgaben. Einmal sahst du in einem Spiegel eine Tür hinter dir, die nicht im Raum war, als du dich umdrehtest.
Danach lerntest du Vorsicht. Es ist unklug für eine Frau, Dinge zu wissen, bevor ein Mann ihre Meinung erbeten hat. Danach lerntest du Vorsicht. Es ist unklug für eine Frau, Dinge zu wissen, bevor ein Mann ihre Meinung erbeten hat.
@@ -522,7 +522,7 @@ Unter Ruf und Aufführung hat die Erinnerung ihre eigene Aussage.
~ supernatural_senses = "repressed" ~ supernatural_senses = "repressed"
~ eccentric += 1 ~ eccentric += 1
Es gibt Kindheitserinnerungen, die hinter Höflichkeit versiegelt sind: ein Kinderzimmerspiegel, zur Wand gedreht; eine Amme, ohne Zeugnis entlassen; die Hand deiner Mutter um dein Handgelenk, so fest, dass die Knochen sich beschwerten. #gloss[Amme](Frau die ein fremdes Kind stillt oder betreut.) #gloss[Zeugnis](Schriftliche Bescheinigung über Dienst Betragen oder Befähigung.) Es gibt Kindheitserinnerungen, die hinter Höflichkeit versiegelt sind: ein Kinderzimmerspiegel, zur Wand gedreht; eine Amme, ohne Zeugnis entlassen; die Hand deiner Mutter um dein Handgelenk, so fest, dass die Knochen sich beschwerten. #gloss[Amme](Frau, die ein fremdes Kind stillt oder früh betreut. Sie kommt dem Körper eines Hauses oft näher als jene, denen das Haus gehört.) #gloss[Zeugnis](Schriftliche Bescheinigung über Dienst, Betragen und Befähigung. Wer keinen Rang besitzt, braucht Papier, das guten Leumund behauptet.)
Danach wurdest du auf Arten sonderbar, die die Gesellschaft leichter bewundern als verstehen konnte. Danach wurdest du auf Arten sonderbar, die die Gesellschaft leichter bewundern als verstehen konnte.
@@ -533,7 +533,7 @@ Unter Ruf und Aufführung hat die Erinnerung ihre eigene Aussage.
Für einige Sekunden gibt dir das Fenster nichts als Viktor, die Lampe, deine Handschuhe, die Linie deines Hutes und den bleichen Umriss deines Gesichts zurück. Der Tunnel löscht jede andere Welt aus. Für einige Sekunden gibt dir das Fenster nichts als Viktor, die Lampe, deine Handschuhe, die Linie deines Hutes und den bleichen Umriss deines Gesichts zurück. Der Tunnel löscht jede andere Welt aus.
Gerade deshalb wird das Glas nützlich. Nachdem Name und Auftrag in dir geordnet sind, zeigt es nicht bloß eine Dame im richtigen Abteil. Es zeigt die Frau, die in Eibenreith aussteigen wird. #gloss[Abteil](Abgeschlossener Raum eines Eisenbahnwagens.) #gloss[Eibenreith](Fiktives Dorf im obersteirischen Gebirge unterhalb von Jagdhaus Hohenreith.) Gerade deshalb wird das Glas nützlich. Nachdem Name und Auftrag in dir geordnet sind, zeigt es nicht bloß eine Dame im richtigen Abteil. Es zeigt die Frau, die in Eibenreith aussteigen wird.
* [__Schaue__: Auf eine kleine, schmale Gestalt.] #action:orientation * [__Schaue__: Auf eine kleine, schmale Gestalt.] #action:orientation
~ body_detail = "small_slender" ~ body_detail = "small_slender"
@@ -549,7 +549,7 @@ Gerade deshalb wird das Glas nützlich. Nachdem Name und Auftrag in dir geordnet
* [__Schaue__: Auf eine kompakte, kräftigere Gestalt.] #action:orientation * [__Schaue__: Auf eine kompakte, kräftigere Gestalt.] #action:orientation
~ body_detail = "compact_strong" ~ body_detail = "compact_strong"
Reisekleidung und Korsett ordnen dich, aber sie verleugnen nicht alles. In deinen Unterarmen, im Nacken, in der Art, wie du ein Gleichgewicht hältst, liegt mehr Kraft, als man einer Dame höflich zutraut. #gloss[Korsett](Formendes Mieder das Taille und Oberkörper stützt.) Reisekleidung und Korsett ordnen dich, aber sie verleugnen nicht alles. In deinen Unterarmen, im Nacken, in der Art, wie du ein Gleichgewicht hältst, liegt mehr Kraft, als man einer Dame höflich zutraut. #gloss[Korsett](Formendes Mieder für Taille und Oberkörper. Es stützt Haltung, beschränkt Atem und beweist, dass weibliche Selbstbeherrschung zuerst am Körper verlangt wird.)
* [__Schaue__: Auf eine zierliche, empfindsam wirkende Gestalt.] #action:orientation * [__Schaue__: Auf eine zierliche, empfindsam wirkende Gestalt.] #action:orientation
~ body_detail = "delicate" ~ body_detail = "delicate"
@@ -573,7 +573,7 @@ Die dunkle Scheibe hält nun Haar und Hut fest.
* [__Schaue__: Auf hellbraunes Haar mit goldenen Strähnen.] #action:orientation * [__Schaue__: Auf hellbraunes Haar mit goldenen Strähnen.] #action:orientation
~ hair_colour = "light_brown_gold" ~ hair_colour = "light_brown_gold"
Hellbraunes Haar mit goldenen Strähnen wirkt im Abteil beinahe zu warm für diese Reise, als habe Wien einen letzten Rest Nachmittag darin vergessen. #gloss[Abteil](Abgeschlossener Raum eines Eisenbahnwagens.) Hellbraunes Haar mit goldenen Strähnen wirkt im Abteil beinahe zu warm für diese Reise, als habe Wien einen letzten Rest Nachmittag darin vergessen.
* [__Schaue__: Auf rotbraunes, sorgfältig gebändigtes Haar.] #action:orientation * [__Schaue__: Auf rotbraunes, sorgfältig gebändigtes Haar.] #action:orientation
~ hair_colour = "auburn" ~ hair_colour = "auburn"
@@ -638,16 +638,16 @@ Der Rest der Spiegelung ist Kostüm, Rüstung und Beweismittel.
- birth_class == "middle": - birth_class == "middle":
Die Kleidung muss eine höhere Welt betreten können, ohne zu schreien, dass sie dafür gearbeitet hat. Die Kleidung muss eine höhere Welt betreten können, ohne zu schreien, dass sie dafür gearbeitet hat.
- else: - else:
Die Kleidung muss beweisen, dass man dich in die erste Klasse setzen konnte, ohne dass der Stoff gegen dich aussagt. #gloss[erste Klasse](Vornehmste Wagenklasse der Eisenbahn mit besserer Polsterung mehr Raum und höherem Fahrpreis.) Die Kleidung muss beweisen, dass man dich in die erste Klasse setzen konnte, ohne dass der Stoff gegen dich aussagt. #gloss[erste Klasse](Die teuerste Wagenklasse der Eisenbahn. Sie bietet weichere Polster, bessere Luft und vor allem weniger Menschen, was im bürgerlichen Fortschrittsglauben oft dasselbe bedeutet.)
} }
* [__Trage__: Ein dunkel anthrazitfarbenes Reisekostüm mit pflaumenfarbenem Samtkragen.] #action:thinking * [__Trage__: Ein dunkel anthrazitfarbenes Reisekostüm mit pflaumenfarbenem Samtkragen.] #action:thinking
~ outfit_detail = "charcoal_plum_velvet" ~ outfit_detail = "charcoal_plum_velvet"
Du trägst ein geschneidertes Reisekostüm aus dunkler anthrazitfarbener Wolle. Am Kragen und an den Manschetten liegt ein pflaumenfarbener Samtton, gedämpft genug für den Tag, teuer genug für Menschen mit Augen. #gloss[Reisekostüm](Damen Reiseanzug aus Rock und Jacke oder Mantel.) #gloss[anthrazitfarbener](Sehr dunkles Grau beinahe schwarz.) Du trägst ein geschneidertes Reisekostüm aus dunkler anthrazitfarbener Wolle. Am Kragen und an den Manschetten liegt ein pflaumenfarbener Samtton, gedämpft genug für den Tag, teuer genug für Menschen mit Augen. #gloss[Reisekostüm](Damenkleidung für die Reise. Fest genug für Bahnhofsschmutz, korrekt genug für fremde Blicke, und unbequem genug, damit niemand vergisst, dass auch Zweckmäßigkeit weiblich auszusehen hat.) #gloss[anthrazitfarbener](Von Anthrazit hergeleitet, also sehr dunkelgrau. Eine Farbe für Menschen, die nicht trauern, aber auch nicht verdächtig fröhlich wirken möchten.)
* [__Trage__: Ein schwarzbraunes Wollkostüm mit elfenbeinfarbener Bluse und schmaler Spitze.] #action:thinking * [__Trage__: Ein schwarzbraunes Wollkostüm mit elfenbeinfarbener Bluse und schmaler Spitze.] #action:thinking
~ outfit_detail = "black_brown_ivory_lace" ~ outfit_detail = "black_brown_ivory_lace"
Der Rock ist dunkel und schwer genug für die Reise, die Jacke streng, die elfenbeinfarbene Bluse am Hals hochgeschlossen. Die Spitze ist schmal, sauber und gefährlich nahe an Frömmigkeit. #gloss[Bluse](Oberteil einer Damenkleidung unter Jacke oder Kostüm.) Der Rock ist dunkel und schwer genug für die Reise, die Jacke streng, die elfenbeinfarbene Bluse am Hals hochgeschlossen. Die Spitze ist schmal, sauber und gefährlich nahe an Frömmigkeit.
* [__Trage__: Ein graublaues Reisekostüm mit kurzem Mantel und praktischen Knöpfen.] #action:thinking * [__Trage__: Ein graublaues Reisekostüm mit kurzem Mantel und praktischen Knöpfen.] #action:thinking
~ outfit_detail = "blue_grey_practical" ~ outfit_detail = "blue_grey_practical"
@@ -659,18 +659,18 @@ Der Rest der Spiegelung ist Kostüm, Rüstung und Beweismittel.
* [__Trage__: Ein schwarzes Reisekleid mit Schleier, zu ernst für bloße Mode.] #action:thinking * [__Trage__: Ein schwarzes Reisekleid mit Schleier, zu ernst für bloße Mode.] #action:thinking
~ outfit_detail = "black_veil_severe" ~ outfit_detail = "black_veil_severe"
Das Schwarz ist nicht Trauer, jedenfalls nicht offiziell. Ein schmaler Schleier, dunkle Handschuhe, glatter Rock, hohe Knopfleiste. Es ist die Art Kleidung, in der skeptische Männer leichter an Ahnungen glauben. #gloss[Schleier](Feines durchsichtiges Gewebe vor Gesicht oder Hut.) Das Schwarz ist nicht Trauer, jedenfalls nicht offiziell. Ein schmaler Schleier, dunkle Handschuhe, glatter Rock, hohe Knopfleiste. Es ist die Art Kleidung, in der skeptische Männer leichter an Ahnungen glauben.
- -
{ {
- outfit_detail == "blue_grey_practical": - outfit_detail == "blue_grey_practical":
Viktor wird dieses Kostüm später vermutlich für ein Zeichen von Vernunft halten. Das ist nützlich, auch wenn es nicht vollständig wahr ist. Viktor wird dieses Kostüm später vermutlich für ein Zeichen von Vernunft halten. Das ist nützlich, auch wenn es nicht vollständig wahr ist.
- outfit_detail == "black_veil_severe": - outfit_detail == "black_veil_severe":
Selbst Viktor wird Mühe haben, diese Kleidung ganz von deinem Ruf als Medium zu trennen. Das ist kein Zufall. #gloss[Medium](Person der man Verkehr mit Geistern oder verborgenen Kräften zuschreibt.) Selbst Viktor wird Mühe haben, diese Kleidung ganz von deinem Ruf als Medium zu trennen. Das ist kein Zufall. #gloss[Medium](Person, durch die Geister oder verborgene Kräfte sprechen sollen. Gesellschaftlich besonders brauchbar, weil eine Frau so Dinge sagen darf, die man ihr als eigene Meinung übel nähme.)
- outfit_detail == "dark_green_black_trim": - outfit_detail == "dark_green_black_trim":
Das Kostüm ist diskret genug für die Reise und bestimmt genug, um in Erinnerung zu bleiben. Es sagt nicht viel. Nur genug. Das Kostüm ist diskret genug für die Reise und bestimmt genug, um in Erinnerung zu bleiben. Es sagt nicht viel. Nur genug.
- else: - else:
Die Kleidung wird in Hohenreith sprechen, bevor du es tust. Das ist der Sinn guter Kleidung und die Gefahr schlechter. #gloss[Hohenreith](Gräfliches Jagdhaus oberhalb von Eibenreith und neuerer Name des Zielortes.) Die Kleidung wird in Hohenreith sprechen, bevor du es tust. Das ist der Sinn guter Kleidung und die Gefahr schlechter.
} }
Als die Berge zurückkehren, wirken sie näher. Als die Berge zurückkehren, wirken sie näher.
@@ -680,18 +680,18 @@ Als die Berge zurückkehren, wirken sie näher.
=== first_viktor_exchange === === first_viktor_exchange ===
{not tut_dialog_intro: {not tut_dialog_intro:
#alert[Wenn eine Wahl in Anführungszeichen steht, sprichst du diese Worte aus. Das Verb davor verrät, ob du antwortest, fragst, mitteilst, spottest oder schweigst.] #alert[Steht eine Zeile in Anführungszeichen, sprichst du sie aus. Das hervorgehobene Verb zeigt, was du tust und meist, an wen du dich richtest: Frage Viktor, Antworte, Spotte oder Widersprich.]
~ tut_dialog_intro = true ~ tut_dialog_intro = true
} }
Er faltet die Zeitung zusammen, obwohl du sehr sicher bist, dass er nicht gelesen hat. Er faltet die Zeitung zusammen, obwohl du sehr sicher bist, dass er nicht gelesen hat.
{birth_class == "noble": {birth_class == "noble":
„Sie sind sehr still, gnädiges Fräulein. Für eine Dame auf ihrer ersten amtlichen Reise beweisen Sie bemerkenswerte Zurückhaltung.“ #gloss[amtliche Reise](Reise im Auftrag einer offiziellen Stelle oder Behörde.) „Sie sind sehr still, gnädiges Fräulein. Für eine Dame auf ihrer ersten amtlichen Reise beweisen Sie bemerkenswerte Zurückhaltung.“ #tts[For Viktor, use a restrained, polished officer's voice. Courteous on the surface, with a faint test hidden underneath.] #gloss[amtliche Reise](Eine Reise im Auftrag einer Behörde oder Hofstelle. Der Unterschied zur privaten Reise besteht vor allem darin, dass man weniger frei ist und mehr Papier mitführt.)
Die Anrede wahrt die Form und vermeidet den Titel. Gerade deshalb verrät sie Absicht. Die Anrede wahrt die Form und vermeidet den Titel. Gerade deshalb verrät sie Absicht.
- else: - else:
„Sie sind sehr still, Fräulein {surname}. Für eine Dame auf ihrer ersten amtlichen Reise beweisen Sie bemerkenswerte Zurückhaltung.“ „Sie sind sehr still, Fräulein {surname}. Für eine Dame auf ihrer ersten amtlichen Reise beweisen Sie bemerkenswerte Zurückhaltung.“ #tts[For Viktor, use a restrained, polished officer's voice. Courteous on the surface, with a faint test hidden underneath.]
Die Anrede ist korrekt, doch sie prüft dich mehr, als sie dich schützt. Er weiß noch nicht, welcher Teil von dir brauchbar ist, welcher Verkleidung und welcher Gefahr. Die Anrede ist korrekt, doch sie prüft dich mehr, als sie dich schützt. Er weiß noch nicht, welcher Teil von dir brauchbar ist, welcher Verkleidung und welcher Gefahr.
} }
@@ -772,9 +772,9 @@ Viktor wartet auf die Antwort, die seine Bemerkung verlangt. Der Zug ruckt einma
~ class_confidence += 1 ~ class_confidence += 1
„Eine Erziehung in Zimmern, in denen selbst jeder Stuhl Rang besitzt.“ „Eine Erziehung in Zimmern, in denen selbst jeder Stuhl Rang besitzt.“
„Dann wird Hohenreith Sie vielleicht nicht überraschen.“ #gloss[Hohenreith](Gräfliches Jagdhaus oberhalb von Eibenreith und neuerer Name des Zielortes.) „Dann wird Hohenreith Sie vielleicht nicht überraschen.“
Die Möglichkeit, dass Hohenreith bessere Geheimnisse als Stühle besitzt, darf unausgesprochen bleiben. #gloss[Hohenreith](Gräfliches Jagdhaus oberhalb von Eibenreith und neuerer Name des Zielortes.) Die Möglichkeit, dass Hohenreith bessere Geheimnisse als Stühle besitzt, darf unausgesprochen bleiben.
-- --
-> viktor_mission_briefing -> viktor_mission_briefing
@@ -825,7 +825,7 @@ Viktors Bemerkung bleibt zwischen euch liegen wie ein höflicher Vorwurf.
** [__Antworte__: „Eine nützliche. Bitterkeit ist nur der Geschmack, den Belehrung zurücklässt.“] #action:conversation ** [__Antworte__: „Eine nützliche. Bitterkeit ist nur der Geschmack, den Belehrung zurücklässt.“] #action:conversation
#route:eccentric #route:eccentric
~ eccentric += 1 ~ eccentric += 1
„Eine nützliche. Bitterkeit ist nur der Geschmack, den Belehrung zurücklässt.“ „Eine nützliche. Bitterkeit ist nur der Geschmack, den Belehrung zurücklässt.“ #tts[Read the protagonist's line dryly and evenly, with a small sting on "Bitterkeit".]
„Sie sammeln Redewendungen wie Waffen.“ „Sie sammeln Redewendungen wie Waffen.“
@@ -847,7 +847,7 @@ Viktors Bemerkung bleibt zwischen euch liegen wie ein höflicher Vorwurf.
#route:lover #route:lover
~ lover += 1 ~ lover += 1
~ medium_reputation += 1 ~ medium_reputation += 1
„Wenn ich schweige, Herr Nowak, so deshalb, weil Männer sich schneller erklären, wenn ihnen die Stille missfällt.“ „Wenn ich schweige, Herr Nowak, so deshalb, weil Männer sich schneller erklären, wenn ihnen die Stille missfällt.“ #tts[Let this be calm and deliberate, almost conversationally intimate. Give "Stille" a tiny pause before continuing.]
„Eine Methode?“ „Eine Methode?“
@@ -887,7 +887,7 @@ Viktors Bemerkung bleibt zwischen euch liegen wie ein höflicher Vorwurf.
~ eccentric += 1 ~ eccentric += 1
„Wie bequem. Die beiden anderen dürfen die Verantwortung leugnen.“ „Wie bequem. Die beiden anderen dürfen die Verantwortung leugnen.“
„Ich rate Ihnen, den Witz in Hohenreith nicht zu Ihrem ersten Werkzeug zu machen.“ #gloss[Hohenreith](Gräfliches Jagdhaus oberhalb von Eibenreith und neuerer Name des Zielortes.) „Ich rate Ihnen, den Witz in Hohenreith nicht zu Ihrem ersten Werkzeug zu machen.“
Die Herabstufung des Witzes zum zweiten Werkzeug bleibt theoretisch genug, um ungefährlich zu sein. Die Herabstufung des Witzes zum zweiten Werkzeug bleibt theoretisch genug, um ungefährlich zu sein.
@@ -905,7 +905,7 @@ Viktors Bemerkung bleibt zwischen euch liegen wie ein höflicher Vorwurf.
=== viktor_class_working === === viktor_class_working ===
Viktors Höflichkeit ist glatt genug, um keine Fingerabdrücke zu behalten. Darunter hörst du die Frage, wie sehr dieses Abteil dich verbessert hat. #gloss[Abteil](Abgeschlossener Raum eines Eisenbahnwagens.) Viktors Höflichkeit ist glatt genug, um keine Fingerabdrücke zu behalten. Darunter hörst du die Frage, wie sehr dieses Abteil dich verbessert hat.
* [__Antworte__: „Zurückhaltung ist, was die Leute loben, wenn sie die Mühe dahinter nicht sehen wollen.“] #action:conversation * [__Antworte__: „Zurückhaltung ist, was die Leute loben, wenn sie die Mühe dahinter nicht sehen wollen.“] #action:conversation
#route:detective #route:detective
@@ -924,7 +924,7 @@ Viktors Höflichkeit ist glatt genug, um keine Fingerabdrücke zu behalten. Daru
„Das dürfte schwer zu vermeiden sein.“ „Das dürfte schwer zu vermeiden sein.“
Wenn Hohenreith dich billig loben will, wird es die Ökonomie der Enttäuschung kennenlernen müssen. #gloss[Hohenreith](Gräfliches Jagdhaus oberhalb von Eibenreith und neuerer Name des Zielortes.) Wenn Hohenreith dich billig loben will, wird es die Ökonomie der Enttäuschung kennenlernen müssen.
** [__Antworte__: „Nur, wenn es die Person verbirgt, die die Arbeit getan hat.“] #action:conversation ** [__Antworte__: „Nur, wenn es die Person verbirgt, die die Arbeit getan hat.“] #action:conversation
#route:sapphic #route:sapphic
@@ -944,6 +944,24 @@ Viktors Höflichkeit ist glatt genug, um keine Fingerabdrücke zu behalten. Daru
„Ich habe Sie nicht um Dankbarkeit gebeten.“ „Ich habe Sie nicht um Dankbarkeit gebeten.“
** [__Summe__: Eine Antwort, die keine Worte braucht.] #action:conversation #optional
#route:eccentric
~ eccentric += 1
Du summst so leise, dass es mehr Haltung als Ton ist. Der Rhythmus der Räder nimmt die Melodie auf und gibt sie höflicher zurück, als sie gekommen ist. #tts[Hum this line very softly, almost under the breath. Keep it restrained, intimate, and wordless, with a faint ironic composure.]
Viktor sieht nicht auf.
„Sie haben die ungewöhnliche Gabe, Schweigen beschäftigt wirken zu lassen.“
** [__Singe__: Die Kaiserhymne, beinahe ohne Stimme.] #action:conversation #optional
#route:eccentric
~ eccentric += 1
Du singst kaum lauter als das Rauschen im Polster: „Gott beschütze Franz den Kaiser ...“ Nicht fromm genug für ein Gebet, nicht spöttisch genug für Hochverrat. #tts[Sing the quoted hymn fragment almost silently, like a controlled private murmur. Use a formal, old Austrian ceremonial feel, but keep the delivery restrained and ambiguous.]
Ein Muskel an Viktors Kiefer bewegt sich.
„Eine riskante Art, Ihre Loyalität nachzuweisen.“
** [__Antworte__: „Nein. Sie haben verlangt, dass ich lenkbar sei.“] #action:conversation ** [__Antworte__: „Nein. Sie haben verlangt, dass ich lenkbar sei.“] #action:conversation
#route:eccentric #route:eccentric
~ eccentric += 1 ~ eccentric += 1
@@ -994,9 +1012,9 @@ Viktors Höflichkeit ist glatt genug, um keine Fingerabdrücke zu behalten. Daru
Der Zug tritt aus dem Tunnel in einen blassen Nachmittag aus dunklen Tannen und weißem Fels. Tief unten zeigt sich Wasser nur in Blitzen. Das Tal ist kein Anblick aus einem Salonbild mehr. Es hat Tiefe genug, um Dinge zu verbergen. Der Zug tritt aus dem Tunnel in einen blassen Nachmittag aus dunklen Tannen und weißem Fels. Tief unten zeigt sich Wasser nur in Blitzen. Das Tal ist kein Anblick aus einem Salonbild mehr. Es hat Tiefe genug, um Dinge zu verbergen.
Viktor öffnet eine Ledermappe und nimmt ein Memorandum heraus. Er reicht es dir nicht sofort. #gloss[Memorandum](Schriftliche Denkschrift oder amtliche Notiz.) Viktor öffnet eine Ledermappe und nimmt ein Memorandum heraus. Er reicht es dir nicht sofort. #gloss[Memorandum](Amtliche Denkschrift. Lang genug, um Zuständigkeit zu behaupten, kurz genug, um die gefährlichen Dinge nicht beim Namen nennen zu müssen.)
„Wenn wir die Bahn verlassen“, sagt er, „werden wir von einer Kutsche aus Hohenreith erwartet. Von diesem Augenblick an sind Äußerlichkeiten von Bedeutung. Ihren Gastgebern wurde mitgeteilt, dass ich bei Korrespondenz, Reiseangelegenheiten und praktischen Vorkehrungen behilflich bin. Mit militärischen Definitionen muss man sie nicht behelligen.“ #gloss[Hohenreith](Gräfliches Jagdhaus oberhalb von Eibenreith und neuerer Name des Zielortes.) #gloss[Korrespondenz](Briefwechsel und schriftliche Erledigung von Nachrichten.) „Wenn wir die Bahn verlassen“, sagt er, „werden wir von einer Kutsche aus Hohenreith erwartet. Von diesem Augenblick an sind Äußerlichkeiten von Bedeutung. Ihren Gastgebern wurde mitgeteilt, dass ich bei Korrespondenz, Reiseangelegenheiten und praktischen Vorkehrungen behilflich bin. Mit militärischen Definitionen muss man sie nicht behelligen.“ #gloss[Korrespondenz](Briefwechsel. Die vornehme Kunst, Absichten so lange zu falten, bis sie in ein Kuvert passen.)
* [__Frage Viktor__: „Und die Dorfbewohner?“] #action:conversation * [__Frage Viktor__: „Und die Dorfbewohner?“] #action:conversation
„Und die Dorfbewohner?“ „Und die Dorfbewohner?“
@@ -1027,7 +1045,7 @@ Viktor öffnet eine Ledermappe und nimmt ein Memorandum heraus. Er reicht es dir
- -
„Man wird Sie nach dem Stand anreden, den Sie vorweisen“, fährt er fort. „Der Haushalt des Grafen wird den Rang beachten. Die Dienerschaft wird beachten, was der Haushalt beachtet. Die Dorfbewohner mögen weniger beachten und mehr behalten. Ich rate zur Zurückhaltung.“ #gloss[Haushalt des Grafen](Häusliche Ordnung eines gräflichen Hauses mit Dienerschaft Verwaltung und Rang.) #gloss[Dienerschaft](Gesamtheit der Bediensteten eines Hauses oder Gutes.) „Man wird Sie nach dem Stand anreden, den Sie vorweisen“, fährt er fort. „Der Haushalt des Grafen wird den Rang beachten. Die Dienerschaft wird beachten, was der Haushalt beachtet. Die Dorfbewohner mögen weniger beachten und mehr behalten. Ich rate zur Zurückhaltung.“ #tts[For Viktor's briefing, sound exact and professional. Keep each sentence cleanly separated, like points in a confidential report.] #gloss[Haushalt des Grafen](Das Haus als Rangordnung. Familie, Dienerschaft, Gäste und Zuständigkeiten bilden darin ein Uhrwerk, das besonders laut tickt, wenn jemand falsch steht.) #gloss[Dienerschaft](Die Bediensteten eines Hauses. Offiziell Teil der Ordnung, praktisch ihr Gedächtnis.)
Der Rat ist vernünftig. Das macht ihn nicht weniger ärgerlich. Der Rat ist vernünftig. Das macht ihn nicht weniger ärgerlich.
@@ -1046,14 +1064,14 @@ Der Rat ist vernünftig. Das macht ihn nicht weniger ärgerlich.
** [__Antworte__: „Eine praktische.“] #action:conversation ** [__Antworte__: „Eine praktische.“] #action:conversation
„Eine praktische.“ „Eine praktische.“
„Sie gedenken, sie in Hohenreith anzuwenden?“ #gloss[Hohenreith](Gräfliches Jagdhaus oberhalb von Eibenreith und neuerer Name des Zielortes.) „Sie gedenken, sie in Hohenreith anzuwenden?“
*** [__Antworte__: „Nur dort, wo die Pflicht gegen die Monarchie Opfer verlangt.“] #action:conversation *** [__Antworte__: „Nur dort, wo die Pflicht gegen die Monarchie Opfer verlangt.“] #action:conversation
#route:lover #route:lover
~ lover += 1 ~ lover += 1
„Nur dort, wo die Pflicht gegen die Monarchie Opfer verlangt.“ „Nur dort, wo die Pflicht gegen die Monarchie Opfer verlangt.“
Er blickt auf das Memorandum hinunter, aber nicht schnell genug, um zu verbergen, dass er dich neu einschätzt. #gloss[Memorandum](Schriftliche Denkschrift oder amtliche Notiz.) Er blickt auf das Memorandum hinunter, aber nicht schnell genug, um zu verbergen, dass er dich neu einschätzt. #gloss[Memorandum](Amtliche Denkschrift. Lang genug, um Zuständigkeit zu behaupten, kurz genug, um die gefährlichen Dinge nicht beim Namen nennen zu müssen.)
*** [__Antworte__: „Nur dort, wo Männer Begehren mit Urteil verwechseln.“] #action:conversation *** [__Antworte__: „Nur dort, wo Männer Begehren mit Urteil verwechseln.“] #action:conversation
#route:lover #route:lover
@@ -1069,9 +1087,9 @@ Der Rat ist vernünftig. Das macht ihn nicht weniger ärgerlich.
~ eccentric += 1 ~ eccentric += 1
„Gefährliche Lehren reisen am besten in guten Handschuhen.“ „Gefährliche Lehren reisen am besten in guten Handschuhen.“
„Sie gedenken, Hohenreith durch Charme zum Geständnis zu bringen?“ #gloss[Hohenreith](Gräfliches Jagdhaus oberhalb von Eibenreith und neuerer Name des Zielortes.) „Sie gedenken, Hohenreith durch Charme zum Geständnis zu bringen?“
Wenn Hohenreith darauf besteht, bezaubert zu werden, wird es kaum deine Schuld sein. #gloss[Hohenreith](Gräfliches Jagdhaus oberhalb von Eibenreith und neuerer Name des Zielortes.) Wenn Hohenreith darauf besteht, bezaubert zu werden, wird es kaum deine Schuld sein.
-- --
@@ -1117,11 +1135,11 @@ Der Rat ist vernünftig. Das macht ihn nicht weniger ärgerlich.
„Sie wissen, dass Sie empfohlen kommen. Sie vermuten, dass Sie imstande sein könnten, die Störungen ohne Polizei, Priester oder Presse beizulegen. Ihnen ist gestattet, Betrug, Zwang, Gefährdung der öffentlichen Ordnung oder glaubwürdige, derzeit nicht einzuordnende Erscheinungen zu prüfen.“ „Sie wissen, dass Sie empfohlen kommen. Sie vermuten, dass Sie imstande sein könnten, die Störungen ohne Polizei, Priester oder Presse beizulegen. Ihnen ist gestattet, Betrug, Zwang, Gefährdung der öffentlichen Ordnung oder glaubwürdige, derzeit nicht einzuordnende Erscheinungen zu prüfen.“
** [__Antworte__: „Glaubwürdige, derzeit nicht einzuordnende Erscheinungen.“] #action:conversation ** [__Antworte__: „Glaubwürdige, derzeit nicht einzuordnende Erscheinungen.“] #action:conversation
„Glaubwürdige, derzeit nicht einzuordnende Erscheinungen.“ „Glaubwürdige, derzeit nicht einzuordnende Erscheinungen.“ #tts[Repeat the phrase with quiet skepticism, tasting the bureaucracy of it.]
„So lautet die Wendung.“ „So lautet die Wendung.“
Die Formulierung setzt sich in deinem Geist fest wie ein bürokratisches Gespenst. Die Formulierung setzt sich in deinem Geist fest wie ein bürokratisches Gespenst. #tts[Make this sentence slightly colder and more uncanny, with a faint emphasis on "bürokratisches Gespenst".]
„Die ungefährlichste Art“, sagt er. „Die ungefährlichste Art“, sagt er.
@@ -1150,7 +1168,7 @@ Der Rat ist vernünftig. Das macht ihn nicht weniger ärgerlich.
„Wie praktisch.“ „Wie praktisch.“
Die Last der Sachlichkeit wandert zu ihm hinüber, so anmutig wie ein Ohnmachtssofa, das in ein Feldlazarett geschleppt wird. #gloss[Ohnmachtssofa](Scherzhafte Bezeichnung für ein kleines Ruhemöbel bei Schwäche oder Ohnmacht.) #gloss[Feldlazarett](Militärische Kranken und Verwundetenstation im Feld.) Die Last der Sachlichkeit wandert zu ihm hinüber, so anmutig wie ein Ohnmachtssofa, das in ein Feldlazarett geschleppt wird. #gloss[Ohnmachtssofa](Ruhemöbel für weibliche Schwächeanfälle, echte oder erwartete. Eine Gesellschaft, die Damen eng schnürt, sorgt immerhin für passende Möbel, wenn sie nicht mehr stehen.) #gloss[Feldlazarett](Militärische Krankenstation im Feld. Dort endet die Sprache von Ehre und beginnt die Sprache von Blut, Listen und Verbänden.)
Seine Antwort verzögert sich um einen halben Atemzug. Seine Antwort verzögert sich um einen halben Atemzug.
@@ -1181,7 +1199,7 @@ Der Rat ist vernünftig. Das macht ihn nicht weniger ärgerlich.
** [__Antworte__: „Nein. Ich missbillige nur die Bequemlichkeit, Dummköpfe unentschieden bleiben zu lassen.“] #action:conversation ** [__Antworte__: „Nein. Ich missbillige nur die Bequemlichkeit, Dummköpfe unentschieden bleiben zu lassen.“] #action:conversation
„Nein. Ich missbillige nur die Bequemlichkeit, Dummköpfe unentschieden bleiben zu lassen.“ „Nein. Ich missbillige nur die Bequemlichkeit, Dummköpfe unentschieden bleiben zu lassen.“
„In Hohenreith könnte diese Abneigung kostspielig werden.“ #gloss[Hohenreith](Gräfliches Jagdhaus oberhalb von Eibenreith und neuerer Name des Zielortes.) „In Hohenreith könnte diese Abneigung kostspielig werden.“
Wenn der Graf Fügsamkeit wollte, hätte er jemand Günstigeren einladen können. Wenn der Graf Fügsamkeit wollte, hätte er jemand Günstigeren einladen können.
@@ -1196,13 +1214,13 @@ Der Rat ist vernünftig. Das macht ihn nicht weniger ärgerlich.
- -
Die Räder nehmen eine Kurve. Das Abteil neigt sich. Für einen Augenblick hält euch dieselbe schmale Schräglage. #gloss[Abteil](Abgeschlossener Raum eines Eisenbahnwagens.) Die Räder nehmen eine Kurve. Das Abteil neigt sich. Für einen Augenblick hält euch dieselbe schmale Schräglage.
Viktor gibt dir endlich das Memorandum. #gloss[Memorandum](Schriftliche Denkschrift oder amtliche Notiz.) Viktor gibt dir endlich das Memorandum. #gloss[Memorandum](Amtliche Denkschrift. Lang genug, um Zuständigkeit zu behaupten, kurz genug, um die gefährlichen Dinge nicht beim Namen nennen zu müssen.)
Das Schriftstück ist nicht lang. Das ist Teil seiner Bedrohlichkeit. Lange Schriftstücke laden zum Widerspruch ein; kurze tragen Autorität. Das Schriftstück ist nicht lang. Das ist Teil seiner Bedrohlichkeit. Lange Schriftstücke laden zum Widerspruch ein; kurze tragen Autorität.
Ein gräflicher Haushalt. Ein Jagdhaus in der Obersteiermark, nicht der Hauptsitz der Familie. Berichte über Störungen unter Dienerschaft und Dorfbewohnern. Kein Einschreiten der Polizei erbeten. Keine öffentliche kirchliche Untersuchung erwünscht. Keine Presse. Keine Korrespondenz außerhalb genehmigter Kanäle. Deine Anwesenheit ist als diskrete Konsultation auf Wunsch der Familie zu erklären. Herr Nowak dient zur Unterstützung praktischer Angelegenheiten. #gloss[Jagdhaus](Ländliches Haus oder kleineres Schloss für Jagdaufenthalte.) #gloss[Obersteiermark](Nördlicher alpiner Teil der Steiermark.) #gloss[Dienerschaft](Gesamtheit der Bediensteten eines Hauses oder Gutes.) #gloss[kirchliche Untersuchung](Prüfung durch kirchliche Stellen.) #gloss[Korrespondenz](Briefwechsel und schriftliche Erledigung von Nachrichten.) Ein gräflicher Haushalt. Ein Jagdhaus in der Obersteiermark, nicht der Hauptsitz der Familie. Berichte über Störungen unter Dienerschaft und Dorfbewohnern. Kein Einschreiten der Polizei erbeten. Keine öffentliche kirchliche Untersuchung erwünscht. Keine Presse. Keine Korrespondenz außerhalb genehmigter Kanäle. Deine Anwesenheit ist als diskrete Konsultation auf Wunsch der Familie zu erklären. Herr Nowak dient zur Unterstützung praktischer Angelegenheiten. #gloss[Jagdhaus](Adeliger Landsitz für Jagden und kurze Aufenthalte. Weniger Hauptsitz als Bühne für Gäste, Förster, Gewehre und Geheimnisse.) #gloss[Obersteiermark](Der gebirgige Norden der Steiermark. Wälder, Eisen, enge Täler und ein Wetter, das sich nicht für Wiener Empfindlichkeiten interessiert.) #gloss[Dienerschaft](Die Bediensteten eines Hauses. Offiziell Teil der Ordnung, praktisch ihr Gedächtnis.) #gloss[kirchliche Untersuchung](Prüfung durch geistliche Stellen. Sie ist besonders beruhigend, solange man sicher ist, dass der Schrecken der richtigen Art angehört.) #gloss[Korrespondenz](Briefwechsel. Die vornehme Kunst, Absichten so lange zu falten, bis sie in ein Kuvert passen.)
Niemand hat das Wort Geist geschrieben. Niemand hat das Wort Geist geschrieben.
@@ -1213,11 +1231,11 @@ Niemand hat das Wort Tochter geschrieben.
Doch die Auslassungen ordnen sich auf der Seite an wie Möbel um eine Leiche. Doch die Auslassungen ordnen sich auf der Seite an wie Möbel um eine Leiche.
* [__Antworte__: „Es gibt noch eine weitere Weisung.“] #action:conversation * [__Antworte__: „Es gibt noch eine weitere Weisung.“] #action:conversation
„Es gibt noch eine weitere Weisung.“ #gloss[Weisung](Amtlicher oder dienstlicher Auftrag dessen Befolgung erwartet wird.) „Es gibt noch eine weitere Weisung.“ #gloss[Weisung](Ein Befehl, dem man die Uniform ausgezogen hat. Sehr nützlich, wenn Gehorsam erwartet wird, aber niemand persönlich Verantwortung tragen möchte.)
Viktor fragt nicht, woher du es weißt. Viktor fragt nicht, woher du es weißt.
„Es gibt immer noch eine weitere Weisung“, sagt er. #gloss[Weisung](Amtlicher oder dienstlicher Auftrag dessen Befolgung erwartet wird.) „Es gibt immer noch eine weitere Weisung“, sagt er. #gloss[Weisung](Ein Befehl, dem man die Uniform ausgezogen hat. Sehr nützlich, wenn Gehorsam erwartet wird, aber niemand persönlich Verantwortung tragen möchte.)
** [__Antworte__: „Für Sie.“] #action:conversation ** [__Antworte__: „Für Sie.“] #action:conversation
„Für Sie.“ „Für Sie.“
@@ -1254,7 +1272,7 @@ Doch die Auslassungen ordnen sich auf der Seite an wie Möbel um eine Leiche.
#route:detective #route:detective
~ detective += 1 ~ detective += 1
~ viktor_trust += 1 ~ viktor_trust += 1
„Ihre Fassung ist kürzer als Ihr Schweigen. Das bedeutet, es gibt noch eine weitere Weisung.“ #gloss[Weisung](Amtlicher oder dienstlicher Auftrag dessen Befolgung erwartet wird.) „Ihre Fassung ist kürzer als Ihr Schweigen. Das bedeutet, es gibt noch eine weitere Weisung.“ #gloss[Weisung](Ein Befehl, dem man die Uniform ausgezogen hat. Sehr nützlich, wenn Gehorsam erwartet wird, aber niemand persönlich Verantwortung tragen möchte.)
Viktor fragt nicht, woher du es weißt. Viktor fragt nicht, woher du es weißt.
@@ -1344,14 +1362,14 @@ Der Zug beginnt langsamer zu werden. Der Rhythmus verändert sich zuerst im Bode
* [__Antworte__: „Dann werde ich die Weisung so kunstvoll enttäuschen, wie es die Umstände erlauben.“] #action:conversation * [__Antworte__: „Dann werde ich die Weisung so kunstvoll enttäuschen, wie es die Umstände erlauben.“] #action:conversation
#route:eccentric #route:eccentric
~ eccentric += 1 ~ eccentric += 1
„Dann werde ich die Weisung so kunstvoll enttäuschen, wie es die Umstände erlauben.“ #gloss[Weisung](Amtlicher oder dienstlicher Auftrag dessen Befolgung erwartet wird.) „Dann werde ich die Weisung so kunstvoll enttäuschen, wie es die Umstände erlauben.“ #gloss[Weisung](Ein Befehl, dem man die Uniform ausgezogen hat. Sehr nützlich, wenn Gehorsam erwartet wird, aber niemand persönlich Verantwortung tragen möchte.)
„Ich hoffe aufrichtig, dass Sie es nicht tun.“ „Ich hoffe aufrichtig, dass Sie es nicht tun.“
* [__Antworte__: „Dann behalten Sie Ihre zweite Weisung, Herr Nowak. Ich bevorzuge Quellen erster Hand.“] #action:conversation * [__Antworte__: „Dann behalten Sie Ihre zweite Weisung, Herr Nowak. Ich bevorzuge Quellen erster Hand.“] #action:conversation
#route:detective #route:detective
~ detective += 1 ~ detective += 1
„Dann behalten Sie Ihre zweite Weisung, Herr Nowak. Ich bevorzuge Quellen erster Hand.“ #gloss[Weisung](Amtlicher oder dienstlicher Auftrag dessen Befolgung erwartet wird.) „Dann behalten Sie Ihre zweite Weisung, Herr Nowak. Ich bevorzuge Quellen erster Hand.“ #gloss[Weisung](Ein Befehl, dem man die Uniform ausgezogen hat. Sehr nützlich, wenn Gehorsam erwartet wird, aber niemand persönlich Verantwortung tragen möchte.)
„Eine Vorliebe, die im kaiserlichen Dienst nicht immer gewährt wird.“ „Eine Vorliebe, die im kaiserlichen Dienst nicht immer gewährt wird.“
+4 -3
View File
@@ -20,15 +20,16 @@ Niemand muss das. Die Nachricht ist bereits ins Dorf eingetreten, auf Wegen schn
Du sitzt sehr gerade, während Eibenreith dich zum ersten Mal betrachtet. Du sitzt sehr gerade, während Eibenreith dich zum ersten Mal betrachtet.
- (dorfbeobachtung)
* [__Schaue__: In die Gesichter am Straßenrand.] #action:orientation #optional #key:l * [__Schaue__: In die Gesichter am Straßenrand.] #action:orientation #optional #key:l
Die Gesichter verschwinden nicht, wenn du hinsiehst. Sie verändern nur ihre Begründung: Eine Frau prüft plötzlich ihren Eimer. Ein Bub entdeckt die Gänse neu. Ein Mann tut, als habe er schon immer zum Kirchtor gesehen. Das Dorf besitzt keine Bühne, aber jeder hier kennt seinen Auftritt. Die Gesichter verschwinden nicht, wenn du hinsiehst. Sie verändern nur ihre Begründung: Eine Frau prüft plötzlich ihren Eimer. Ein Bub entdeckt die Gänse neu. Ein Mann tut, als habe er schon immer zum Kirchtor gesehen. Das Dorf besitzt keine Bühne, aber jeder hier kennt seinen Auftritt.
-> village_arrival_options -> dorfbeobachtung
* [__Höre__: Auf das Wasser unter der Straße.] #action:orientation #optional * [__Höre__: Auf das Wasser unter der Straße.] #action:orientation #optional
Unter den Rädern, unter Brettern und Steinen, unter der höflichen Behauptung einer Dorfstraße läuft Wasser. Es klingt nicht tief, aber schnell. Als hätte der Ort einen zweiten Atem, einen kalten, verborgenen, der nicht durch menschliche Münder geht. Unter den Rädern, unter Brettern und Steinen, unter der höflichen Behauptung einer Dorfstraße läuft Wasser. Es klingt nicht tief, aber schnell. Als hätte der Ort einen zweiten Atem, einen kalten, verborgenen, der nicht durch menschliche Münder geht.
-> village_arrival_options -> dorfbeobachtung
* [__Untersuche__: Die Kirche.] #action:orientation #optional * [__Untersuche__: Die Kirche.] #action:orientation #optional
Der Turm ist nicht schlank genug, um in den Himmel zu zeigen. Er steht da wie eine Faust. Die kleinen Fenster geben wenig preis, und die Mauer des Kirchhofs wirkt weniger wie Einfriedung als wie eine alte Gewohnheit, sich gegen etwas zu stemmen. Der Turm ist nicht schlank genug, um in den Himmel zu zeigen. Er steht da wie eine Faust. Die kleinen Fenster geben wenig preis, und die Mauer des Kirchhofs wirkt weniger wie Einfriedung als wie eine alte Gewohnheit, sich gegen etwas zu stemmen.
@@ -42,7 +43,7 @@ Du sitzt sehr gerade, während Eibenreith dich zum ersten Mal betrachtet.
Die Kirche sieht nicht aus, als habe sie den älteren Dingen im Tal widersprochen. Eher, als habe sie gelernt, über ihnen zu stehen. Die Kirche sieht nicht aus, als habe sie den älteren Dingen im Tal widersprochen. Eher, als habe sie gelernt, über ihnen zu stehen.
} }
-> village_arrival_options -> dorfbeobachtung
* [__Warte__: Bis die Kutsche hält.] #action:social #key:z * [__Warte__: Bis die Kutsche hält.] #action:social #key:z
-> village_exit_puzzle -> village_exit_puzzle
File diff suppressed because one or more lines are too long
+3
View File
@@ -21,6 +21,9 @@ export declare class InkEngine {
private restoreState; private restoreState;
private loadStory; private loadStory;
private continueStory; private continueStory;
private isParagraphScopedTag;
private reassignTrailingGlossTags;
private normalizeGlossMatchText;
private getChoiceTags; private getChoiceTags;
private extractChoicePreviewTags; private extractChoicePreviewTags;
private resolveInkPath; private resolveInkPath;
+69 -2
View File
@@ -129,6 +129,7 @@ class InkEngine {
const paragraphs = []; const paragraphs = [];
const globalTags = []; const globalTags = [];
const turnTags = []; const turnTags = [];
let pendingParagraphTags = [];
while (this.story.canContinue) { while (this.story.canContinue) {
const rawText = this.story.Continue(); const rawText = this.story.Continue();
const text = String(rawText || '').trim(); const text = String(rawText || '').trim();
@@ -138,11 +139,25 @@ class InkEngine {
.filter((tag) => tag.key === 'title' || tag.key === 'author') .filter((tag) => tag.key === 'title' || tag.key === 'author')
.forEach((tag) => globalTags.push(tag)); .forEach((tag) => globalTags.push(tag));
if (text) { if (text) {
paragraphs.push({ text, tags }); const paragraphTags = this.reassignTrailingGlossTags(text, [...pendingParagraphTags, ...tags], paragraphs);
pendingParagraphTags = [];
paragraphs.push({ text, tags: paragraphTags });
} }
else { else {
tags.forEach((tag) => globalTags.push(tag)); const paragraphTags = this.reassignTrailingGlossTags('', tags, paragraphs);
paragraphTags.forEach((tag) => {
if (this.isParagraphScopedTag(tag)) {
pendingParagraphTags.push(tag);
} }
else {
globalTags.push(tag);
}
});
}
}
if (pendingParagraphTags.length > 0) {
globalTags.push(...pendingParagraphTags);
pendingParagraphTags = [];
} }
const choices = this.story.currentChoices.map((choice) => { const choices = this.story.currentChoices.map((choice) => {
const tags = this.getChoiceTags(choice); const tags = this.getChoiceTags(choice);
@@ -195,6 +210,58 @@ class InkEngine {
gameState: Object.keys(gameState).length > 0 ? gameState : undefined, gameState: Object.keys(gameState).length > 0 ? gameState : undefined,
}; };
} }
isParagraphScopedTag(tag) {
const key = String(tag?.key || '').toLowerCase();
return ['chapter', 'heading', 'section', 'textblock', 'image', 'music', 'sfx', 'sound', 'audio', 'gloss', 'tts']
.includes(key) || key.startsWith('tts-');
}
reassignTrailingGlossTags(text, tags, paragraphs) {
if (!Array.isArray(tags) || tags.length === 0)
return [];
const previous = paragraphs.length > 0 ? paragraphs[paragraphs.length - 1] : null;
if (!previous)
return tags;
const currentText = this.normalizeGlossMatchText(text);
const previousText = this.normalizeGlossMatchText(previous.text);
const remainingTags = [];
tags.forEach((tag) => {
if (tag.key === 'tts' || tag.key.startsWith('tts-')) {
if (!currentText) {
previous.tags.push(tag);
}
else {
remainingTags.push(tag);
}
return;
}
if (tag.key !== 'gloss') {
remainingTags.push(tag);
return;
}
const term = this.normalizeGlossMatchText(tag.value || '');
if (!term) {
remainingTags.push(tag);
return;
}
const matchesCurrent = currentText.includes(term);
const matchesPrevious = previousText.includes(term);
if (!matchesCurrent && matchesPrevious) {
previous.tags.push(tag);
}
else {
remainingTags.push(tag);
}
});
return remainingTags;
}
normalizeGlossMatchText(value) {
return String(value || '')
.normalize('NFC')
.toLocaleLowerCase('de-DE')
.replace(/[.,;:!?()[\]{}"'„“”‚‘’»«]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
getChoiceTags(choice) { getChoiceTags(choice) {
const directTags = (0, tag_parser_1.parseTags)(choice?.tags || []); const directTags = (0, tag_parser_1.parseTags)(choice?.tags || []);
const previewTags = this.extractChoicePreviewTags(choice); const previewTags = this.extractChoicePreviewTags(choice);
+1 -1
View File
File diff suppressed because one or more lines are too long
+7 -1
View File
@@ -120,7 +120,13 @@ async function handleGameApi(socket, method, args) {
const engine = new ink_engine_1.InkEngine(getStoryPath()); const engine = new ink_engine_1.InkEngine(getStoryPath());
sessions.set(socket.id, engine); sessions.set(socket.id, engine);
socket.emit('narrativeResponse', engine.newGame()); socket.emit('narrativeResponse', engine.newGame());
return { success: true, result: true, running: true, canLoad: slots.size > 0 }; return {
success: true,
result: true,
running: true,
canLoad: slots.size > 0,
savedState: engine.saveGame(),
};
} }
case 'chooseChoice': case 'chooseChoice':
case 'chooseChoice()': { case 'chooseChoice()': {
+1 -1
View File
File diff suppressed because one or more lines are too long
+6 -3
View File
@@ -1668,7 +1668,8 @@ body:not([data-game-running="true"]) #start_prompt {
.modal-footer button, .modal-footer button,
.option-item input[type="text"], .option-item input[type="text"],
.option-item input[type="password"], .option-item input[type="password"],
.option-item input[type="url"] { .option-item input[type="url"],
.option-item input[type="number"] {
background-color: transparent; background-color: transparent;
border: 1px solid var(--panel-border); border: 1px solid var(--panel-border);
border-radius: var(--control-radius); border-radius: var(--control-radius);
@@ -1684,7 +1685,8 @@ body:not([data-game-running="true"]) #start_prompt {
.option-item input[type="text"], .option-item input[type="text"],
.option-item input[type="password"], .option-item input[type="password"],
.option-item input[type="url"] { .option-item input[type="url"],
.option-item input[type="number"] {
box-sizing: border-box; box-sizing: border-box;
width: min(18rem, 60%); width: min(18rem, 60%);
padding: 0.3rem 0.5rem; padding: 0.3rem 0.5rem;
@@ -1692,7 +1694,8 @@ body:not([data-game-running="true"]) #start_prompt {
.option-item input[type="text"]:focus, .option-item input[type="text"]:focus,
.option-item input[type="password"]:focus, .option-item input[type="password"]:focus,
.option-item input[type="url"]:focus { .option-item input[type="url"]:focus,
.option-item input[type="number"]:focus {
outline: none; outline: none;
box-shadow: 0 0 0 2px rgba(90, 57, 33, 0.14); box-shadow: 0 0 0 2px rgba(90, 57, 33, 0.14);
} }
+8 -5
View File
@@ -17,7 +17,7 @@ class AnimationQueueModule extends BaseModule {
// Animation timing properties - use parent's config system // Animation timing properties - use parent's config system
this.updateConfig({ this.updateConfig({
speed: 1.0, // Speed multiplier for delays (1.0 = no scaling, delays are pre-calculated) speed: 1.0,
fastForwardEnabled: false fastForwardEnabled: false
}); });
@@ -44,7 +44,9 @@ class AnimationQueueModule extends BaseModule {
// Listen for speed changes from UI // Listen for speed changes from UI
document.addEventListener('animation:speed:change', (event) => { document.addEventListener('animation:speed:change', (event) => {
if (event.detail && typeof event.detail.speed === 'number') { if (event.detail && typeof event.detail.speed === 'number') {
// Speed from UI is a rate multiplier (0.5-2.0 typically) // Word timings are already speed-scaled before they reach
// the scheduler. Keep the value only for diagnostics/API
// compatibility; do not apply it again in schedule().
this.config.speed = event.detail.speed; this.config.speed = event.detail.speed;
console.log(`AnimationQueue: Speed updated to ${this.config.speed}`); console.log(`AnimationQueue: Speed updated to ${this.config.speed}`);
} }
@@ -71,8 +73,9 @@ class AnimationQueueModule extends BaseModule {
return -1; return -1;
} }
// Adjust delay based on fast-forward or speed settings // Delays are absolute timings calculated from the prepared sentence
const actualDelay = this.config.fastForwardEnabled ? 0 : Math.max(0, delay * this.config.speed); // duration. TTS/app speed has already been applied at that stage.
const actualDelay = this.config.fastForwardEnabled ? 0 : Math.max(0, delay);
// Record the delay for tracking // Record the delay for tracking
this.delay = Math.max(this.delay, delay); this.delay = Math.max(this.delay, delay);
@@ -318,7 +321,7 @@ class AnimationQueueModule extends BaseModule {
/** /**
* Set the animation speed * Set the animation speed
* @param {number} speed - Animation speed factor (lower is faster) * @param {number} speed - Stored speed value for compatibility/diagnostics
*/ */
setSpeed(speed) { setSpeed(speed) {
if (typeof speed !== 'number' || speed <= 0) { if (typeof speed !== 'number' || speed <= 0) {
+29 -11
View File
@@ -27,7 +27,7 @@ export class BrowserTTSModule extends TTSHandlerModule {
this.currentUtterance = null; this.currentUtterance = null;
// Bind additional methods // Bind additional methods
this.bindMethods(['handleVoicePreferenceChanged']); this.bindMethods(['handleVoicePreferenceChanged', 'estimateSpeechDuration']);
} }
/** /**
@@ -368,26 +368,29 @@ export class BrowserTTSModule extends TTSHandlerModule {
// Set up event handlers // Set up event handlers
utterance.onstart = this.utteranceHandlers.start; utterance.onstart = this.utteranceHandlers.start;
utterance.onpause = this.utteranceHandlers.pause;
utterance.onresume = this.utteranceHandlers.resume;
// Start speaking
this.currentUtterance = utterance;
return new Promise(resolve => {
utterance.onend = () => { utterance.onend = () => {
this.utteranceHandlers.end(); this.utteranceHandlers.end();
if (callback) { if (callback) {
callback({ success: true }); callback({ success: true });
} }
resolve(true);
}; };
utterance.onerror = (event) => { utterance.onerror = (event) => {
this.utteranceHandlers.error(event); this.utteranceHandlers.error(event);
if (callback) { if (callback) {
callback({ success: false, reason: 'synthesis_error', error: event }); callback({ success: false, reason: 'synthesis_error', error: event });
} }
resolve(false);
}; };
utterance.onpause = this.utteranceHandlers.pause;
utterance.onresume = this.utteranceHandlers.resume;
// Start speaking
this.currentUtterance = utterance;
speechSynthesis.speak(utterance); speechSynthesis.speak(utterance);
});
return true;
} catch (error) { } catch (error) {
console.error('Browser TTS: Failed to speak:', error); console.error('Browser TTS: Failed to speak:', error);
if (callback) { if (callback) {
@@ -469,7 +472,7 @@ export class BrowserTTSModule extends TTSHandlerModule {
if (typeof options.speed === 'number') { if (typeof options.speed === 'number') {
// Web Speech rate uses 1.0 as normal, matching the app-wide slider. // Web Speech rate uses 1.0 as normal, matching the app-wide slider.
this.voiceOptions.speed = Math.max(0.1, Math.min(10.0, options.speed)); this.voiceOptions.speed = Math.max(0.5, Math.min(2.0, options.speed));
} }
if (typeof options.pitch === 'number') { if (typeof options.pitch === 'number') {
@@ -494,8 +497,23 @@ export class BrowserTTSModule extends TTSHandlerModule {
* @returns {Promise<Object>} - Promise that resolves to null * @returns {Promise<Object>} - Promise that resolves to null
*/ */
async preloadSpeech(text) { async preloadSpeech(text) {
// Browser TTS can't preload speech if (!this.isReady || !text) {
return { success: false, reason: 'not_supported' }; return { success: false, reason: 'not_ready_or_empty_text' };
}
return {
success: true,
text,
duration: this.estimateSpeechDuration(text),
directPlayback: true
};
}
estimateSpeechDuration(text) {
const processedText = this.preprocessText(text);
const charactersPerSecond = 12;
const speed = Math.max(0.5, Math.min(2.0, Number(this.voiceOptions.speed) || 1.0));
return Math.max((processedText.length / (charactersPerSecond * speed)) * 1000, 800);
} }
/** /**
+58 -2
View File
@@ -35,6 +35,9 @@ class ChoiceDisplayModule extends BaseModule {
'render', 'render',
'clear', 'clear',
'normalizeChoices', 'normalizeChoices',
'orderChoicesForPresentation',
'shuffleChoices',
'randomInt',
'assignLetters', 'assignLetters',
'selectChoice', 'selectChoice',
'getTagValue', 'getTagValue',
@@ -137,7 +140,7 @@ class ChoiceDisplayModule extends BaseModule {
} }
normalizeChoices(choices) { normalizeChoices(choices) {
return this.assignLetters(choices.slice(0, 36).map((choice, order) => { const normalized = choices.slice(0, 36).map((choice, order) => {
const tags = Array.isArray(choice.tags) ? choice.tags : []; const tags = Array.isArray(choice.tags) ? choice.tags : [];
const category = choice.category || this.getTagValue(tags, 'action'); const category = choice.category || this.getTagValue(tags, 'action');
return { return {
@@ -145,11 +148,64 @@ class ChoiceDisplayModule extends BaseModule {
text: String(choice.text || ''), text: String(choice.text || ''),
tags, tags,
category, category,
sourceOrder: order,
optional: this.hasTag(tags, 'optional'), optional: this.hasTag(tags, 'optional'),
letter: '', letter: '',
templateCell: this.getTemplateCell({ ...choice, tags, category }) templateCell: this.getTemplateCell({ ...choice, tags, category })
}; };
})); });
return this.assignLetters(this.orderChoicesForPresentation(normalized));
}
orderChoicesForPresentation(choices) {
const groupOrder = [];
const grouped = new Map();
const ungrouped = [];
choices.forEach((choice) => {
const group = String(choice.category || '').trim();
if (!group) {
ungrouped.push(choice);
return;
}
if (!grouped.has(group)) {
grouped.set(group, []);
groupOrder.push(group);
}
grouped.get(group).push(choice);
});
const ordered = [];
groupOrder.forEach((group) => {
ordered.push(...this.shuffleChoices(grouped.get(group) || []));
});
if (ungrouped.length > 0) {
ordered.push(...this.shuffleChoices(ungrouped));
}
return ordered;
}
shuffleChoices(choices) {
const shuffled = choices.slice();
for (let index = shuffled.length - 1; index > 0; index -= 1) {
const swapIndex = this.randomInt(index + 1);
[shuffled[index], shuffled[swapIndex]] = [shuffled[swapIndex], shuffled[index]];
}
return shuffled;
}
randomInt(exclusiveMax) {
const max = Math.max(1, Number(exclusiveMax) || 1);
if (window.crypto && typeof window.crypto.getRandomValues === 'function') {
const values = new Uint32Array(1);
window.crypto.getRandomValues(values);
return values[0] % max;
}
return Math.floor(Math.random() * max);
} }
assignLetters(choices) { assignLetters(choices) {
+13 -3
View File
@@ -75,7 +75,7 @@ export class ElevenLabsTTSModule extends ApiTTSModuleBase {
const preferredSpeed = persistenceManager.getPreference('tts', 'speed', this.voiceOptions.speed); const preferredSpeed = persistenceManager.getPreference('tts', 'speed', this.voiceOptions.speed);
if (typeof preferredSpeed === 'number') { if (typeof preferredSpeed === 'number') {
this.voiceOptions.speed = this.getApiSpeed(preferredSpeed); this.voiceOptions.speed = this.normalizeAppSpeed(preferredSpeed);
} }
this.isReady = true; this.isReady = true;
@@ -255,7 +255,7 @@ export class ElevenLabsTTSModule extends ApiTTSModuleBase {
} }
if (typeof options.speed === 'number') { if (typeof options.speed === 'number') {
this.voiceOptions.speed = this.getApiSpeed(options.speed); this.voiceOptions.speed = this.normalizeAppSpeed(options.speed);
} }
// Handle ElevenLabs-specific options // Handle ElevenLabs-specific options
@@ -271,7 +271,17 @@ export class ElevenLabsTTSModule extends ApiTTSModuleBase {
} }
getApiSpeed(speed) { getApiSpeed(speed) {
return Math.max(0.7, Math.min(1.2, Number.isFinite(speed) ? speed : 1.0)); const appSpeed = this.normalizeAppSpeed(speed);
if (appSpeed <= 1.0) {
return 0.7 + ((appSpeed - 0.5) / 0.5) * 0.3;
}
return 1.0 + (appSpeed - 1.0) * 0.2;
}
normalizeAppSpeed(speed) {
const value = Number.isFinite(Number(speed)) ? Number(speed) : 1.0;
return Math.max(0.5, Math.min(2.0, value));
} }
} }
+30 -1
View File
@@ -210,9 +210,21 @@ class GameLoopModule extends BaseModule {
return false; return false;
} }
await this.resetClientPlaybackAndDisplay();
this.currentChoices = [];
this.currentInputMode = 'none';
document.dispatchEvent(new CustomEvent('story:choices', { detail: [] }));
document.dispatchEvent(new CustomEvent('story:input-mode', { detail: 'none' }));
document.dispatchEvent(new CustomEvent('story:history-restoring', {
detail: { active: true, reason: 'autosave-reconnect-prepare' }
}));
const response = await socketClient.resumeGame(browserSave.inkState); const response = await socketClient.resumeGame(browserSave.inkState);
if (!response?.success) { if (!response?.success) {
console.warn('GameLoop: autosave resume failed', response); console.warn('GameLoop: autosave resume failed', response);
document.dispatchEvent(new CustomEvent('story:history-restoring', {
detail: { active: false, reason: 'autosave-reconnect-failed' }
}));
return false; return false;
} }
@@ -222,7 +234,7 @@ class GameLoopModule extends BaseModule {
this.gameState.canSave = this.gameState.started; this.gameState.canSave = this.gameState.started;
this.gameState.canLoad = true; this.gameState.canLoad = true;
this.updateUIState(); this.updateUIState();
await this.restoreBrowserSave(browserSave, 'autosave-resume', { resetDisplay: true }); await this.restoreBrowserSave(browserSave, 'autosave-resume', { resetDisplay: false });
this.restoreInputStateFromSave(browserSave, 'autosave-resume'); this.restoreInputStateFromSave(browserSave, 'autosave-resume');
return true; return true;
} }
@@ -281,6 +293,14 @@ class GameLoopModule extends BaseModule {
const storyHistory = this.getModule('story-history'); const storyHistory = this.getModule('story-history');
if (storyHistory && typeof storyHistory.startNewGame === 'function') { if (storyHistory && typeof storyHistory.startNewGame === 'function') {
await storyHistory.startNewGame(); await storyHistory.startNewGame();
if (typeof storyHistory.saveSlot === 'function') {
await storyHistory.saveSlot(this.autoSaveSlot, {
inkState: null,
choices: [],
inputMode: 'none',
running: false
});
}
} }
const response = await socketClient.newGame(); const response = await socketClient.newGame();
if (!response?.success) { if (!response?.success) {
@@ -296,6 +316,15 @@ class GameLoopModule extends BaseModule {
this.gameState.canSave = true; this.gameState.canSave = true;
this.gameState.canLoad = Boolean(response.canLoad); this.gameState.canLoad = Boolean(response.canLoad);
this.updateUIState(); this.updateUIState();
if (response.savedState && storyHistory && typeof storyHistory.saveSlot === 'function') {
await storyHistory.saveSlot(this.autoSaveSlot, {
inkState: response.savedState,
choices: [],
inputMode: 'none',
running: true
});
this.lastInkState = response.savedState;
}
} }
/** /**
+30 -1
View File
@@ -20,6 +20,7 @@ export class KokoroTTSModule extends TTSHandlerModule {
this.lastProgressTime = null; this.lastProgressTime = null;
this.lastProgressValue = null; this.lastProgressValue = null;
this.modelLoaded = false; this.modelLoaded = false;
this.unsupportedReason = '';
// Options for playback // Options for playback
this.options = { this.options = {
@@ -37,7 +38,8 @@ export class KokoroTTSModule extends TTSHandlerModule {
'pause', 'pause',
'resume', 'resume',
'getDefaultVoices', 'getDefaultVoices',
'setVoiceOptions' 'setVoiceOptions',
'supportsGameLanguage'
]); ]);
} }
@@ -59,6 +61,18 @@ export class KokoroTTSModule extends TTSHandlerModule {
return false; return false;
} }
const gameConfig = this.getModule('game-config');
const gameLanguage = gameConfig?.getLocale?.() || 'en_US';
if (!this.supportsGameLanguage(gameLanguage)) {
this.voices = [];
this.isReady = false;
this.unsupportedReason = `Kokoro TTS supports English and Chinese only; game language is ${gameLanguage}`;
this.reportProgress(100, 'Kokoro TTS disabled for this language');
console.log(`Kokoro TTS: ${this.unsupportedReason}`);
return true;
}
this.unsupportedReason = '';
this.addEventListener(document, 'preference-updated', (event) => { this.addEventListener(document, 'preference-updated', (event) => {
const { category, key } = event.detail || {}; const { category, key } = event.detail || {};
if (category === 'audio' && ['masterVolume', 'ttsVolume', 'masterVolumeEnabled', 'ttsVolumeEnabled'].includes(key) && this.currentAudio) { if (category === 'audio' && ['masterVolume', 'ttsVolume', 'masterVolumeEnabled', 'ttsVolumeEnabled'].includes(key) && this.currentAudio) {
@@ -389,11 +403,26 @@ export class KokoroTTSModule extends TTSHandlerModule {
return Math.max(0, Math.min(1, this.options.volume * (masterEnabled ? masterVolume : 0) * (ttsEnabled ? ttsVolume : 0))); return Math.max(0, Math.min(1, this.options.volume * (masterEnabled ? masterVolume : 0) * (ttsEnabled ? ttsVolume : 0)));
} }
supportsGameLanguage(language) {
const normalized = String(language || '').trim().replace('_', '-').toLowerCase();
const languageCode = normalized.split('-')[0];
return languageCode === 'en'
|| languageCode === 'english'
|| languageCode === 'zh'
|| languageCode === 'chinese'
|| languageCode === 'cmn'
|| languageCode === 'yue';
}
/** /**
* Get available voices * Get available voices
* @returns {Array} - Array of voice objects * @returns {Array} - Array of voice objects
*/ */
async getVoices() { async getVoices() {
if (this.unsupportedReason) {
return [];
}
// If no voices are loaded yet, return default voices // If no voices are loaded yet, return default voices
if (!this.voices || this.voices.length === 0) { if (!this.voices || this.voices.length === 0) {
return this.getDefaultVoices(); return this.getDefaultVoices();
+184 -5
View File
@@ -27,6 +27,12 @@ class LayoutRendererModule extends BaseModule {
'decorateInlineWord', 'decorateInlineWord',
'applyGlossaryEntries', 'applyGlossaryEntries',
'normalizeGlossaryText', 'normalizeGlossaryText',
'normalizeGlossaryToken',
'normalizeGlossaryCompact',
'buildGlossaryTermPatterns',
'buildCompactGlossaryTermPatterns',
'decorateGlossarySegment',
'decorateGlossaryRange',
'decorateGlossaryWord', 'decorateGlossaryWord',
'ensureGlossaryTooltip', 'ensureGlossaryTooltip',
'showGlossaryTooltip', 'showGlossaryTooltip',
@@ -337,34 +343,56 @@ class LayoutRendererModule extends BaseModule {
let cursor = 0; let cursor = 0;
const segments = []; const segments = [];
let compactCursor = 0;
const compactSegments = [];
const fullText = words.map((word, index) => { const fullText = words.map((word, index) => {
if (index > 0) cursor += 1; if (index > 0) cursor += 1;
const start = cursor; const start = cursor;
cursor += word.text.length; cursor += word.text.length;
segments.push({ ...word, start, end: cursor }); segments.push({ ...word, start, end: cursor });
const compactText = this.normalizeGlossaryCompact(word.text);
if (compactText) {
const compactStart = compactCursor;
compactCursor += compactText.length;
compactSegments.push({ ...word, start: compactStart, end: compactCursor });
}
return word.text; return word.text;
}).join(' '); }).join(' ');
const compactFullText = words.map(word => this.normalizeGlossaryCompact(word.text)).join('');
entries entries
.filter(entry => entry && entry.term && entry.definition) .filter(entry => entry && entry.term && entry.definition)
.forEach(entry => { .forEach(entry => {
const normalizedTerm = this.normalizeGlossaryText(entry.term); this.buildGlossaryTermPatterns(entry.term).forEach((pattern) => {
if (!normalizedTerm) return; const matcher = new RegExp(`(^|\\s)(${pattern})(?=\\s|$|[.,;:!?])`, 'giu');
const matcher = new RegExp(`(^|\\s)(${this.escapeRegExp(normalizedTerm)})(?=\\s|$|[.,;:!?])`, 'giu');
let match; let match;
while ((match = matcher.exec(fullText)) !== null) { while ((match = matcher.exec(fullText)) !== null) {
const matchStart = match.index + match[1].length; const matchStart = match.index + match[1].length;
const matchEnd = matchStart + match[2].length; const matchEnd = matchStart + match[2].length;
segments segments
.filter(segment => segment.end > matchStart && segment.start < matchEnd) .filter(segment => segment.end > matchStart && segment.start < matchEnd)
.forEach(segment => this.decorateGlossaryWord(segment.element, entry)); .forEach(segment => this.decorateGlossarySegment(segment, entry, matchStart, matchEnd, 'text'));
} }
}); });
this.buildCompactGlossaryTermPatterns(entry.term).forEach((pattern) => {
const matcher = new RegExp(pattern, 'giu');
let match;
while ((match = matcher.exec(compactFullText)) !== null) {
const matchStart = match.index;
const matchEnd = matchStart + match[0].length;
compactSegments
.filter(segment => segment.end > matchStart && segment.start < matchEnd)
.forEach(segment => this.decorateGlossarySegment(segment, entry, matchStart, matchEnd, 'compact'));
}
});
});
} }
normalizeGlossaryText(text) { normalizeGlossaryText(text) {
return String(text || '') return String(text || '')
.normalize('NFC')
.replace(/\u200c/g, '') .replace(/\u200c/g, '')
.replace(/\u00ad/g, '') .replace(/\u00ad/g, '')
.replace(/-\s*$/g, '') .replace(/-\s*$/g, '')
@@ -372,6 +400,157 @@ class LayoutRendererModule extends BaseModule {
.trim(); .trim();
} }
normalizeGlossaryToken(text) {
return this.normalizeGlossaryText(text)
.replace(/^[.,;:!?()[\]{}"'„“”‚‘’»«]+|[.,;:!?()[\]{}"'„“”‚‘’»«]+$/g, '');
}
normalizeGlossaryCompact(text) {
return this.normalizeGlossaryToken(text)
.replace(/[-\s]+/g, '')
.replace(/[.,;:!?()[\]{}"'„“”‚‘’»«]+/g, '');
}
buildGlossaryTermPatterns(term) {
const normalizedTerm = this.normalizeGlossaryText(term);
if (!normalizedTerm) return [];
const exact = normalizedTerm
.split(/\s+/)
.map(token => this.escapeRegExp(this.normalizeGlossaryToken(token)))
.filter(Boolean)
.join('\\s+');
if (!exact) return [];
const inflected = normalizedTerm
.split(/\s+/)
.map((token, index, tokens) => {
const normalized = this.normalizeGlossaryToken(token);
if (!normalized) return '';
const escaped = this.escapeRegExp(normalized);
const isLast = index === tokens.length - 1;
return isLast ? `${escaped}(?:s|es|e|en|er|n)?` : `${escaped}(?:e|en|er|es|n)?`;
})
.filter(Boolean)
.join('\\s+');
return [...new Set([exact, inflected])];
}
buildCompactGlossaryTermPatterns(term) {
const tokens = this.normalizeGlossaryText(term)
.split(/\s+/)
.map(token => this.normalizeGlossaryCompact(token))
.filter(Boolean);
if (tokens.length === 0) return [];
const exact = tokens.map(token => this.escapeRegExp(token)).join('');
const inflected = tokens
.map((token, index) => {
const escaped = this.escapeRegExp(token);
const isLast = index === tokens.length - 1;
return isLast ? `${escaped}(?:s|es|e|en|er|n)?` : `${escaped}(?:e|en|er|es|n)?`;
})
.join('');
return [...new Set([exact, inflected])];
}
decorateGlossarySegment(segment, entry, matchStart, matchEnd, mode = 'text') {
if (!segment?.element || !entry?.definition) return;
const localStart = Math.max(0, matchStart - segment.start);
const localEnd = Math.min(segment.end - segment.start, matchEnd - segment.start);
if (localEnd <= localStart) return;
const segmentLength = mode === 'compact'
? this.normalizeGlossaryCompact(segment.text).length
: segment.text.length;
if (localStart <= 0 && localEnd >= segmentLength) {
this.decorateGlossaryWord(segment.element, entry);
return;
}
if (mode === 'compact') {
return;
}
this.decorateGlossaryRange(segment.element, entry, localStart, localEnd);
}
decorateGlossaryRange(word, entry, start, end) {
if (!word || !entry?.definition) return;
const text = word.textContent || '';
const safeStart = Math.max(0, Math.min(text.length, start));
const safeEnd = Math.max(safeStart, Math.min(text.length, end));
if (safeStart === 0 && safeEnd >= text.length) {
this.decorateGlossaryWord(word, entry);
return;
}
if (safeEnd <= safeStart) return;
word.dataset.glossaryPartial = 'true';
const textNodes = [];
const filter = window.NodeFilter || NodeFilter;
const walker = document.createTreeWalker(word, filter.SHOW_TEXT);
let node;
while ((node = walker.nextNode())) {
textNodes.push(node);
}
let offset = 0;
textNodes.forEach((textNode) => {
const nodeText = textNode.nodeValue || '';
const nodeStart = offset;
const nodeEnd = nodeStart + nodeText.length;
offset = nodeEnd;
const overlapStart = Math.max(safeStart, nodeStart);
const overlapEnd = Math.min(safeEnd, nodeEnd);
if (overlapEnd <= overlapStart || !textNode.parentNode) return;
const localStart = overlapStart - nodeStart;
const localEnd = overlapEnd - nodeStart;
const before = nodeText.slice(0, localStart);
const matched = nodeText.slice(localStart, localEnd);
const after = nodeText.slice(localEnd);
const parent = textNode.parentNode;
if (before) {
parent.insertBefore(document.createTextNode(before), textNode);
}
if (matched) {
const gloss = document.createElement('span');
gloss.textContent = matched;
this.decorateGlossaryWord(gloss, entry);
parent.insertBefore(gloss, textNode);
}
if (after) {
parent.insertBefore(document.createTextNode(after), textNode);
}
parent.removeChild(textNode);
});
if (textNodes.length === 0) {
const before = text.slice(0, safeStart);
const matched = text.slice(safeStart, safeEnd);
const after = text.slice(safeEnd);
word.textContent = '';
if (before) word.appendChild(document.createTextNode(before));
const gloss = document.createElement('span');
gloss.textContent = matched;
this.decorateGlossaryWord(gloss, entry);
word.appendChild(gloss);
if (after) word.appendChild(document.createTextNode(after));
}
}
decorateGlossaryWord(word, entry) { decorateGlossaryWord(word, entry) {
if (!word || !entry?.definition) return; if (!word || !entry?.definition) return;
word.classList.add('story-glossary-word'); word.classList.add('story-glossary-word');
+1
View File
@@ -122,6 +122,7 @@ const ModuleLoader = (function() {
{ id: 'browser-tts', script: '/js/browser-tts-module.js', weight: 12 }, { id: 'browser-tts', script: '/js/browser-tts-module.js', weight: 12 },
{ id: 'elevenlabs-tts', script: '/js/elevenlabs-tts-module.js', weight: 12 }, { id: 'elevenlabs-tts', script: '/js/elevenlabs-tts-module.js', weight: 12 },
{ id: 'openai-tts', script: '/js/openai-tts-module.js', weight: 12 }, { id: 'openai-tts', script: '/js/openai-tts-module.js', weight: 12 },
{ id: 'local-openai-tts', script: '/js/local-openai-tts-module.js', weight: 12 },
{ id: 'tts-factory', script: '/js/tts-factory-module.js', weight: 13 }, // TTSFactory must be loaded before TTSPlayer { id: 'tts-factory', script: '/js/tts-factory-module.js', weight: 13 }, // TTSFactory must be loaded before TTSPlayer
// UI and interaction modules // UI and interaction modules
+259
View File
@@ -0,0 +1,259 @@
/**
* LocalOpenAITTSModule
* Provides TTS via local or self-hosted OpenAI-compatible /audio/speech APIs.
*/
import { ApiTTSModuleBase } from './api-tts-module-base.js';
export class LocalOpenAITTSModule extends ApiTTSModuleBase {
constructor() {
super('local-openai-tts', 'Local OpenAI TTS');
this.voiceOptions = {
voice: 'alloy',
model: 'tts-1',
speed: 1.0,
response_format: 'mp3'
};
this.voices = [];
}
getDefaultApiBaseUrl() {
return 'http://localhost:8000/v1';
}
async initialize() {
try {
this.reportProgress(10, 'Initializing Local OpenAI TTS');
const parentInit = await super.initialize();
if (!parentInit) {
console.error('Local OpenAI TTS: Parent initialization failed');
return false;
}
const persistenceManager = this.getModule('persistence-manager');
if (!persistenceManager) {
console.error('Local OpenAI TTS: Required dependency persistence-manager not found');
return false;
}
const preferredVoice = persistenceManager.getPreference('tts', `${this.id}_voice`, this.voiceOptions.voice);
if (preferredVoice) {
this.voiceOptions.voice = this.normalizeTextOption(preferredVoice, this.voiceOptions.voice);
}
const preferredModel = persistenceManager.getPreference('tts', `${this.id}_model`, this.voiceOptions.model);
if (preferredModel) {
this.voiceOptions.model = this.normalizeTextOption(preferredModel, this.voiceOptions.model);
}
const preferredFormat = persistenceManager.getPreference('tts', `${this.id}_format`, this.voiceOptions.response_format);
if (preferredFormat) {
this.voiceOptions.response_format = this.normalizeResponseFormat(preferredFormat);
}
const preferredSpeed = persistenceManager.getPreference('tts', 'speed', this.voiceOptions.speed);
if (typeof preferredSpeed === 'number') {
this.voiceOptions.speed = this.normalizeAppSpeed(preferredSpeed);
}
this.isReady = Boolean(this.apiBaseUrl && this.voiceOptions.voice && this.voiceOptions.model);
this.reportProgress(100, this.isReady ? 'Local OpenAI TTS initialized' : 'Local OpenAI TTS not configured');
return true;
} catch (error) {
console.error('Local OpenAI TTS: Initialization error:', error);
this.isReady = false;
return false;
}
}
async loadVoices() {
this.voices = [];
return true;
}
selectVoiceForLocale() {
return this.selectDefaultVoice();
}
selectDefaultVoice() {
this.voiceOptions.voice = this.normalizeTextOption(this.voiceOptions.voice, 'alloy');
return true;
}
getAvailableVoices() {
return [];
}
async getVoices() {
return [];
}
async generateSpeechAudio(text, options = {}) {
if (!this.isReady || !this.apiBaseUrl) {
return { success: false, reason: 'not_ready' };
}
try {
const processedText = this.preprocessText(text);
if (!processedText) {
return { success: false, reason: 'empty_text' };
}
const payload = {
model: this.normalizeTextOption(this.voiceOptions.model, 'tts-1'),
input: processedText,
voice: this.normalizeTextOption(this.voiceOptions.voice, 'alloy'),
response_format: this.normalizeResponseFormat(this.voiceOptions.response_format),
speed: this.getApiSpeed(this.voiceOptions.speed)
};
const headers = {
'Content-Type': 'application/json'
};
if (this.apiKey) {
headers.Authorization = `Bearer ${this.apiKey}`;
}
const response = await fetch(`${this.apiBaseUrl.replace(/\/+$/, '')}/audio/speech`, {
method: 'POST',
headers,
body: JSON.stringify(payload),
signal: options.signal
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`API error: ${response.status} ${response.statusText} - ${errorText}`);
}
const audioBlob = await response.blob();
const arrayBuffer = await audioBlob.arrayBuffer();
return {
success: true,
audioData: arrayBuffer
};
} catch (error) {
if (error?.name === 'AbortError') {
console.error('Local OpenAI TTS: Speech request was aborted:', error);
return {
success: false,
reason: 'aborted',
error: error.message
};
}
console.error('Local OpenAI TTS: Error generating speech:', error);
return {
success: false,
reason: 'api_error',
error: error.message
};
}
}
setVoiceOptions(options = {}) {
const persistenceManager = this.getModule('persistence-manager');
if (typeof options.voice === 'string') {
this.voiceOptions.voice = this.normalizeTextOption(options.voice, this.voiceOptions.voice);
if (persistenceManager) {
persistenceManager.updatePreference('tts', `${this.id}_voice`, this.voiceOptions.voice);
}
}
if (typeof options.speed === 'number') {
this.voiceOptions.speed = this.normalizeAppSpeed(options.speed);
}
if (typeof options.model === 'string') {
this.voiceOptions.model = this.normalizeTextOption(options.model, this.voiceOptions.model);
if (persistenceManager) {
persistenceManager.updatePreference('tts', `${this.id}_model`, this.voiceOptions.model);
}
}
if (typeof options.response_format === 'string') {
this.voiceOptions.response_format = this.normalizeResponseFormat(options.response_format);
if (persistenceManager) {
persistenceManager.updatePreference('tts', `${this.id}_format`, this.voiceOptions.response_format);
}
}
this.isReady = Boolean(this.apiBaseUrl && this.voiceOptions.voice && this.voiceOptions.model);
this.notifyReadyState();
}
handleApiKeyChanged(event) {
if (!event?.detail || event.detail.provider !== this.id) return;
const newKey = event.detail.key || '';
if (newKey && /^https?:\/\//i.test(newKey)) {
console.error('Local OpenAI TTS: Received URL instead of API key, ignoring it');
return;
}
const oldKey = this.apiKey;
this.apiKey = newKey;
const persistenceManager = this.getModule('persistence-manager');
if (persistenceManager && oldKey !== newKey) {
persistenceManager.updatePreference('tts', `${this.id}_api_key`, newKey);
}
const wasReady = this.isReady;
this.isReady = Boolean(this.apiBaseUrl && this.voiceOptions.voice && this.voiceOptions.model);
if (wasReady !== this.isReady) {
this.notifyReadyState();
}
}
handleApiUrlChanged(event) {
if (!event?.detail || event.detail.provider !== this.id) return;
const oldUrl = this.apiBaseUrl;
const newUrl = String(event.detail.url || this.getDefaultApiBaseUrl()).trim().replace(/\/+$/, '');
this.apiBaseUrl = newUrl;
const persistenceManager = this.getModule('persistence-manager');
if (persistenceManager && oldUrl !== newUrl) {
persistenceManager.updatePreference('tts', `${this.id}_api_url`, newUrl);
}
const wasReady = this.isReady;
this.isReady = Boolean(this.apiBaseUrl && this.voiceOptions.voice && this.voiceOptions.model);
if (wasReady !== this.isReady || oldUrl !== newUrl) {
this.notifyReadyState();
}
}
normalizeTextOption(value, fallback) {
const text = String(value || '').trim();
return text || fallback;
}
normalizeResponseFormat(value) {
const format = String(value || '').trim().toLowerCase();
const validFormats = ['mp3', 'opus', 'aac', 'flac', 'wav', 'pcm'];
return validFormats.includes(format) ? format : 'mp3';
}
getApiSpeed(speed) {
const value = Number.isFinite(Number(speed)) ? Number(speed) : this.normalizeAppSpeed(speed);
return Math.max(0.25, Math.min(4.0, value));
}
normalizeAppSpeed(speed) {
const value = Number.isFinite(Number(speed)) ? Number(speed) : 1.0;
return Math.max(0.5, Math.min(2.0, value));
}
}
const localOpenAITTSModule = new LocalOpenAITTSModule();
export { localOpenAITTSModule };
if (window.moduleRegistry) {
window.moduleRegistry.register(localOpenAITTSModule);
}
window.LocalOpenAITTSModule = localOpenAITTSModule;
+48
View File
@@ -19,6 +19,8 @@ class MarkupParserModule extends BaseModule {
'parseParagraph', 'parseParagraph',
'parseInline', 'parseInline',
'extractGlossaryTags', 'extractGlossaryTags',
'extractTtsInstructionTags',
'normalizeTtsInstructionProvider',
'parseImageOptions', 'parseImageOptions',
'parseSfxOptions', 'parseSfxOptions',
'parseMusicOptions', 'parseMusicOptions',
@@ -243,6 +245,52 @@ class MarkupParserModule extends BaseModule {
.sort((a, b) => b.term.length - a.term.length); .sort((a, b) => b.term.length - a.term.length);
} }
extractTtsInstructionTags(tags = []) {
if (!Array.isArray(tags)) return [];
return tags
.map(tag => {
const key = String(tag?.key || '').toLowerCase();
const value = String(tag?.value || '').trim();
const param = String(tag?.param || '').trim();
if (key === 'tts') {
if (param) {
return {
provider: this.normalizeTtsInstructionProvider(value),
instruction: param
};
}
return {
provider: null,
instruction: value
};
}
if (key.startsWith('tts-') && value) {
return {
provider: this.normalizeTtsInstructionProvider(key.slice(4)),
instruction: value
};
}
return null;
})
.filter(entry => entry && entry.instruction);
}
normalizeTtsInstructionProvider(provider) {
const normalized = String(provider || '').trim().toLowerCase();
if (!normalized) return null;
if (normalized === 'openai' || normalized === 'openai-tts') return 'openai-tts';
if (normalized === 'local-openai' || normalized === 'local-openai-tts') return 'local-openai-tts';
if (normalized === 'elevenlabs' || normalized === 'elevenlabs-tts') return 'elevenlabs-tts';
if (normalized === 'kokoro' || normalized === 'kokoro-tts') return 'kokoro-tts';
if (normalized === 'browser' || normalized === 'browser-tts') return 'browser-tts';
return normalized;
}
smartypants(text) { smartypants(text) {
const result = String(text) const result = String(text)
.replace(/---/g, '\u2014') .replace(/---/g, '\u2014')
+106 -18
View File
@@ -8,7 +8,13 @@ export class OpenAITTSModule extends ApiTTSModuleBase {
constructor() { constructor() {
super('openai-tts', 'OpenAI TTS'); super('openai-tts', 'OpenAI TTS');
this.supportedVoices = [ this.supportedModels = [
{ id: 'tts-1', name: 'TTS-1' },
{ id: 'tts-1-hd', name: 'TTS-1 HD' },
{ id: 'gpt-4o-mini-tts', name: 'GPT-4o mini TTS' }
];
this.legacyVoices = [
{ id: 'alloy', name: 'Alloy', language: 'en' }, { id: 'alloy', name: 'Alloy', language: 'en' },
{ id: 'ash', name: 'Ash', language: 'en' }, { id: 'ash', name: 'Ash', language: 'en' },
{ id: 'coral', name: 'Coral', language: 'en' }, { id: 'coral', name: 'Coral', language: 'en' },
@@ -20,6 +26,25 @@ export class OpenAITTSModule extends ApiTTSModuleBase {
{ id: 'shimmer', name: 'Shimmer', language: 'en' } { id: 'shimmer', name: 'Shimmer', language: 'en' }
]; ];
this.gpt4oMiniVoices = [
{ id: 'alloy', name: 'Alloy', language: 'en' },
{ id: 'ash', name: 'Ash', language: 'en' },
{ id: 'ballad', name: 'Ballad', language: 'en' },
{ id: 'coral', name: 'Coral', language: 'en' },
{ id: 'echo', name: 'Echo', language: 'en' },
{ id: 'fable', name: 'Fable', language: 'en' },
{ id: 'nova', name: 'Nova', language: 'en' },
{ id: 'onyx', name: 'Onyx', language: 'en' },
{ id: 'sage', name: 'Sage', language: 'en' },
{ id: 'shimmer', name: 'Shimmer', language: 'en' },
{ id: 'verse', name: 'Verse', language: 'en' },
{ id: 'marin', name: 'Marin', language: 'en' },
{ id: 'cedar', name: 'Cedar', language: 'en' }
];
this.supportedVoices = [...this.gpt4oMiniVoices];
this.supportsTtsInstructions = true;
// Voice options specific to OpenAI // Voice options specific to OpenAI
this.voiceOptions = { this.voiceOptions = {
voice: 'alloy', // Default voice for OpenAI voice: 'alloy', // Default voice for OpenAI
@@ -62,15 +87,6 @@ export class OpenAITTSModule extends ApiTTSModuleBase {
return false; return false;
} }
// API key is already loaded in parent initialize() method
// Just check if it's available
if (!this.apiKey) {
console.info('OpenAI TTS: API key not configured; provider unavailable until configured');
this.isReady = false;
this.reportProgress(100, 'OpenAI TTS not configured');
return true;
}
// Load preferences // Load preferences
const preferredVoice = persistenceManager.getPreference('tts', `${this.id}_voice`, this.voiceOptions.voice); const preferredVoice = persistenceManager.getPreference('tts', `${this.id}_voice`, this.voiceOptions.voice);
if (preferredVoice) { if (preferredVoice) {
@@ -79,12 +95,25 @@ export class OpenAITTSModule extends ApiTTSModuleBase {
const preferredModel = persistenceManager.getPreference('tts', `${this.id}_model`, this.voiceOptions.model); const preferredModel = persistenceManager.getPreference('tts', `${this.id}_model`, this.voiceOptions.model);
if (preferredModel) { if (preferredModel) {
this.voiceOptions.model = preferredModel; this.voiceOptions.model = this.normalizeModelId(preferredModel);
} }
this.voices = this.getAvailableVoices();
this.voiceOptions.voice = this.normalizeVoiceId(this.voiceOptions.voice);
const preferredSpeed = persistenceManager.getPreference('tts', 'speed', this.voiceOptions.speed); const preferredSpeed = persistenceManager.getPreference('tts', 'speed', this.voiceOptions.speed);
if (typeof preferredSpeed === 'number') { if (typeof preferredSpeed === 'number') {
this.voiceOptions.speed = this.getApiSpeed(preferredSpeed); this.voiceOptions.speed = this.normalizeAppSpeed(preferredSpeed);
}
// API key is already loaded in parent initialize() method.
// Model and voice preferences still need to be available for the
// options UI even before credentials are configured.
if (!this.apiKey) {
console.info('OpenAI TTS: API key not configured; provider unavailable until configured');
this.isReady = false;
this.reportProgress(100, 'OpenAI TTS not configured');
return true;
} }
const apiReachable = await this.loadVoices(); const apiReachable = await this.loadVoices();
@@ -164,10 +193,14 @@ export class OpenAITTSModule extends ApiTTSModuleBase {
* @returns {Array} - Array of voice objects * @returns {Array} - Array of voice objects
*/ */
getAvailableVoices() { getAvailableVoices() {
this.voices = [...this.supportedVoices]; this.voices = this.getVoicesForModel(this.voiceOptions.model);
return this.voices; return this.voices;
} }
async getVoices() {
return this.getAvailableVoices();
}
/** /**
* Generate speech audio data using OpenAI API * Generate speech audio data using OpenAI API
* @param {string} text - Text to generate speech for * @param {string} text - Text to generate speech for
@@ -191,6 +224,11 @@ export class OpenAITTSModule extends ApiTTSModuleBase {
speed: this.getApiSpeed(this.voiceOptions.speed) speed: this.getApiSpeed(this.voiceOptions.speed)
}; };
const instructions = this.getRequestInstructions(options);
if (instructions) {
payload.instructions = instructions;
}
// Make API request // Make API request
const response = await fetch(`${this.apiBaseUrl}/audio/speech`, { const response = await fetch(`${this.apiBaseUrl}/audio/speech`, {
method: 'POST', method: 'POST',
@@ -246,17 +284,20 @@ export class OpenAITTSModule extends ApiTTSModuleBase {
if (typeof options.speed === 'number') { if (typeof options.speed === 'number') {
// OpenAI speech speed uses 1.0 as normal. The app-wide slider also // OpenAI speech speed uses 1.0 as normal. The app-wide slider also
// uses 1.0 as normal, so only clamp at the provider API boundary. // uses 1.0 as normal, so only clamp at the provider API boundary.
this.voiceOptions.speed = this.getApiSpeed(options.speed); this.voiceOptions.speed = this.normalizeAppSpeed(options.speed);
} }
// Handle OpenAI-specific options // Handle OpenAI-specific options
if (options.model) { if (options.model) {
this.voiceOptions.model = options.model; this.voiceOptions.model = this.normalizeModelId(options.model);
this.voices = this.getAvailableVoices();
this.voiceOptions.voice = this.normalizeVoiceId(this.voiceOptions.voice);
// Save the model preference // Save the model preference
const persistenceManager = this.getModule('persistence-manager'); const persistenceManager = this.getModule('persistence-manager');
if (persistenceManager) { if (persistenceManager) {
persistenceManager.updatePreference('tts', `${this.id}_model`, options.model); persistenceManager.updatePreference('tts', `${this.id}_model`, this.voiceOptions.model);
persistenceManager.updatePreference('tts', `${this.id}_voice`, this.voiceOptions.voice);
} }
} }
@@ -283,7 +324,7 @@ export class OpenAITTSModule extends ApiTTSModuleBase {
normalizeVoiceId(voice) { normalizeVoiceId(voice) {
const voiceId = this.getVoiceId(voice).toLowerCase(); const voiceId = this.getVoiceId(voice).toLowerCase();
const supported = new Set(this.supportedVoices.map(item => item.id)); const supported = new Set(this.getVoicesForModel(this.voiceOptions.model).map(item => item.id));
if (supported.has(voiceId)) { if (supported.has(voiceId)) {
return voiceId; return voiceId;
@@ -296,10 +337,57 @@ export class OpenAITTSModule extends ApiTTSModuleBase {
return 'alloy'; return 'alloy';
} }
normalizeModelId(model) {
const modelId = String(model || '').trim();
const supported = new Set(this.supportedModels.map(item => item.id));
if (supported.has(modelId)) {
return modelId;
}
if (modelId) {
console.warn(`OpenAI TTS: Unsupported model "${modelId}", falling back to tts-1-hd`);
}
return 'tts-1-hd';
}
getVoicesForModel(model) {
const modelId = this.normalizeModelId(model || this.voiceOptions.model);
if (modelId === 'gpt-4o-mini-tts') {
return [...this.gpt4oMiniVoices];
}
return [...this.legacyVoices];
}
getRequestInstructions(options = {}) {
if (this.normalizeModelId(this.voiceOptions.model) !== 'gpt-4o-mini-tts') {
return '';
}
const instructions = Array.isArray(options.ttsInstructions)
? options.ttsInstructions
: [];
const matching = instructions
.filter(entry => {
const provider = String(entry?.provider || '').trim();
return !provider || provider === this.id;
})
.map(entry => String(entry?.instruction || '').trim())
.filter(Boolean);
return matching.length > 0 ? matching[matching.length - 1] : '';
}
getApiSpeed(speed) { getApiSpeed(speed) {
const value = Number.isFinite(speed) ? speed : 1.0; const value = Number.isFinite(Number(speed)) ? Number(speed) : this.normalizeAppSpeed(speed);
return Math.max(0.25, Math.min(4.0, value)); return Math.max(0.25, Math.min(4.0, value));
} }
normalizeAppSpeed(speed) {
const value = Number.isFinite(Number(speed)) ? Number(speed) : 1.0;
return Math.max(0.5, Math.min(2.0, value));
}
} }
const openAITTSModule = new OpenAITTSModule(); const openAITTSModule = new OpenAITTSModule();
+197 -2
View File
@@ -37,6 +37,8 @@ class OptionsUIModule extends BaseModule {
'createModal', 'createModal',
'populateTtsSystems', 'populateTtsSystems',
'populateVoices', 'populateVoices',
'ensureSelectedVoiceIsAvailable',
'updateVoiceControlVisibility',
'populateLanguages', 'populateLanguages',
'loadPreferences', 'loadPreferences',
'createVolumeControl', 'createVolumeControl',
@@ -233,10 +235,10 @@ class OptionsUIModule extends BaseModule {
this.elements.ttsSpeed = createUIElement('input', { this.elements.ttsSpeed = createUIElement('input', {
type: 'range', type: 'range',
min: 50, min: 50,
max: 150, max: 200,
value: 100, value: 100,
'data-pref-bind': 'tts.speed', 'data-pref-bind': 'tts.speed',
'data-pref-transform': 'centered-speed' 'data-pref-transform': 'multiplier-percent'
}, null, speedContainer); }, null, speedContainer);
// Update displayed value when slider changes // Update displayed value when slider changes
@@ -302,6 +304,14 @@ class OptionsUIModule extends BaseModule {
'data-pref-bind': 'tts.voice' 'data-pref-bind': 'tts.voice'
}, null, ttsVoiceContainer); }, null, ttsVoiceContainer);
this.elements.localOpenAiVoice = createUIElement('input', {
id: 'local-openai-voice',
type: 'text',
placeholder: 'alloy',
'data-pref-bind': 'tts.local-openai-tts_voice'
}, null, ttsVoiceContainer);
this.elements.localOpenAiVoice.style.display = 'none';
ttsSection.appendChild(ttsVoiceContainer); ttsSection.appendChild(ttsVoiceContainer);
// Add API Settings // Add API Settings
@@ -504,9 +514,107 @@ class OptionsUIModule extends BaseModule {
openaiSettings.appendChild(openaiApiUrlContainer); openaiSettings.appendChild(openaiApiUrlContainer);
const openaiModelContainer = document.createElement('div');
openaiModelContainer.className = 'option-item';
const openaiModelLabel = document.createElement('label');
openaiModelLabel.textContent = this.t('options.model') + ':';
openaiModelContainer.appendChild(openaiModelLabel);
this.elements.openaiModel = createUIElement('select', {
id: 'openai-model',
'data-pref-bind': 'tts.openai-tts_model'
}, null, openaiModelContainer);
[
{ id: 'tts-1', name: 'TTS-1' },
{ id: 'tts-1-hd', name: 'TTS-1 HD' },
{ id: 'gpt-4o-mini-tts', name: 'GPT-4o mini TTS' }
].forEach(model => {
const option = document.createElement('option');
option.value = model.id;
option.textContent = model.name;
this.elements.openaiModel.appendChild(option);
});
openaiSettings.appendChild(openaiModelContainer);
// Local OpenAI-compatible API settings
const localOpenAiSettings = document.createElement('div');
localOpenAiSettings.className = 'api-settings local-openai-tts-settings';
localOpenAiSettings.style.display = 'none';
const localOpenAiTitle = document.createElement('h3');
localOpenAiTitle.textContent = this.t('options.localOpenAiSettings');
localOpenAiSettings.appendChild(localOpenAiTitle);
const localOpenAiApiKeyContainer = document.createElement('div');
localOpenAiApiKeyContainer.className = 'option-item';
const localOpenAiApiKeyLabel = document.createElement('label');
localOpenAiApiKeyLabel.textContent = this.t('options.optionalApiKey') + ':';
localOpenAiApiKeyContainer.appendChild(localOpenAiApiKeyLabel);
this.elements.localOpenAiApiKey = createUIElement('input', {
type: 'password',
'data-pref-bind': 'tts.local-openai-tts_api_key'
}, null, localOpenAiApiKeyContainer);
localOpenAiSettings.appendChild(localOpenAiApiKeyContainer);
const localOpenAiApiUrlContainer = document.createElement('div');
localOpenAiApiUrlContainer.className = 'option-item';
const localOpenAiApiUrlLabel = document.createElement('label');
localOpenAiApiUrlLabel.textContent = this.t('options.apiUrl') + ':';
localOpenAiApiUrlContainer.appendChild(localOpenAiApiUrlLabel);
this.elements.localOpenAiApiUrl = createUIElement('input', {
type: 'text',
'data-pref-bind': 'tts.local-openai-tts_api_url'
}, null, localOpenAiApiUrlContainer);
localOpenAiSettings.appendChild(localOpenAiApiUrlContainer);
const localOpenAiModelContainer = document.createElement('div');
localOpenAiModelContainer.className = 'option-item';
const localOpenAiModelLabel = document.createElement('label');
localOpenAiModelLabel.textContent = this.t('options.model') + ':';
localOpenAiModelContainer.appendChild(localOpenAiModelLabel);
this.elements.localOpenAiModel = createUIElement('input', {
id: 'local-openai-model',
type: 'text',
placeholder: 'tts-1',
'data-pref-bind': 'tts.local-openai-tts_model'
}, null, localOpenAiModelContainer);
localOpenAiSettings.appendChild(localOpenAiModelContainer);
const localOpenAiTimeoutContainer = document.createElement('div');
localOpenAiTimeoutContainer.className = 'option-item';
const localOpenAiTimeoutLabel = document.createElement('label');
localOpenAiTimeoutLabel.textContent = this.t('options.requestTimeoutMs') + ':';
localOpenAiTimeoutContainer.appendChild(localOpenAiTimeoutLabel);
this.elements.localOpenAiTimeout = createUIElement('input', {
id: 'local-openai-timeout-ms',
type: 'number',
min: 1000,
max: 600000,
step: 1000,
'data-pref-bind': 'tts.local-openai-tts_timeout_ms',
'data-pref-transform': 'integer:1000,600000'
}, null, localOpenAiTimeoutContainer);
localOpenAiSettings.appendChild(localOpenAiTimeoutContainer);
// Add all API settings to container // Add all API settings to container
apiSettings.appendChild(elevenLabsSettings); apiSettings.appendChild(elevenLabsSettings);
apiSettings.appendChild(openaiSettings); apiSettings.appendChild(openaiSettings);
apiSettings.appendChild(localOpenAiSettings);
return apiSettings; return apiSettings;
} }
@@ -622,6 +730,15 @@ class OptionsUIModule extends BaseModule {
if (!ttsFactory || !this.elements.ttsVoice) return; if (!ttsFactory || !this.elements.ttsVoice) return;
const selectedHandler = this.elements.ttsSystem?.value || this.getPreference('tts', 'preferred_handler', 'none'); const selectedHandler = this.elements.ttsSystem?.value || this.getPreference('tts', 'preferred_handler', 'none');
this.updateVoiceControlVisibility(selectedHandler);
if (selectedHandler === 'local-openai-tts') {
if (this.elements.localOpenAiVoice) {
this.elements.localOpenAiVoice.value = this.getPreference('tts', 'local-openai-tts_voice', 'alloy');
}
return;
}
const voices = typeof ttsFactory.getVoicesForHandler === 'function' const voices = typeof ttsFactory.getVoicesForHandler === 'function'
? await ttsFactory.getVoicesForHandler(selectedHandler) || [] ? await ttsFactory.getVoicesForHandler(selectedHandler) || []
: await ttsFactory.getVoices() || []; : await ttsFactory.getVoices() || [];
@@ -635,6 +752,34 @@ class OptionsUIModule extends BaseModule {
'name', 'name',
this.getPreference('tts', `${selectedHandler}_voice`, this.getPreference('tts', 'voice', '')) this.getPreference('tts', `${selectedHandler}_voice`, this.getPreference('tts', 'voice', ''))
); );
this.ensureSelectedVoiceIsAvailable(selectedHandler, voices);
}
ensureSelectedVoiceIsAvailable(selectedHandler, voices = []) {
if (!this.elements.ttsVoice || selectedHandler === 'local-openai-tts') return;
if (!Array.isArray(voices) || voices.length === 0) return;
const available = new Set(voices.map(voice => String(voice.id || '').toLowerCase()));
const current = String(this.elements.ttsVoice.value || '').toLowerCase();
if (current && available.has(current)) return;
const fallback = voices.some(voice => voice.id === 'alloy') ? 'alloy' : voices[0].id;
this.elements.ttsVoice.value = fallback;
this.updatePreference('tts', 'voice', fallback);
if (selectedHandler && selectedHandler !== 'none') {
this.updatePreference('tts', `${selectedHandler}_voice`, fallback);
}
}
updateVoiceControlVisibility(selectedHandler) {
const useTextVoice = selectedHandler === 'local-openai-tts';
if (this.elements.ttsVoice) {
this.elements.ttsVoice.style.display = useTextVoice ? 'none' : '';
}
if (this.elements.localOpenAiVoice) {
this.elements.localOpenAiVoice.style.display = useTextVoice ? '' : 'none';
}
} }
renderProviderStatuses() { renderProviderStatuses() {
@@ -698,6 +843,7 @@ class OptionsUIModule extends BaseModule {
// Update API settings visibility based on current TTS system // Update API settings visibility based on current TTS system
if (this.elements.ttsSystem) { if (this.elements.ttsSystem) {
this.updateApiSettingsVisibility(this.elements.ttsSystem.value); this.updateApiSettingsVisibility(this.elements.ttsSystem.value);
this.updateVoiceControlVisibility(this.elements.ttsSystem.value);
} }
} }
@@ -753,6 +899,36 @@ class OptionsUIModule extends BaseModule {
if (!this.getPreference('tts', 'openai-tts_api_key')) { if (!this.getPreference('tts', 'openai-tts_api_key')) {
this.updatePreference('tts', 'openai-tts_api_key', ''); this.updatePreference('tts', 'openai-tts_api_key', '');
} }
if (!this.getPreference('tts', 'openai-tts_model')) {
this.updatePreference('tts', 'openai-tts_model', 'tts-1-hd');
}
if (this.elements.localOpenAiApiUrl) {
const savedUrl = this.getPreference('tts', 'local-openai-tts_api_url');
const defaultUrl = 'http://localhost:8000/v1';
if (!savedUrl) {
console.log('Options UI: Setting default local OpenAI-compatible API URL:', defaultUrl);
this.updatePreference('tts', 'local-openai-tts_api_url', defaultUrl);
}
}
if (!this.getPreference('tts', 'local-openai-tts_api_key')) {
this.updatePreference('tts', 'local-openai-tts_api_key', '');
}
if (!this.getPreference('tts', 'local-openai-tts_voice')) {
this.updatePreference('tts', 'local-openai-tts_voice', 'alloy');
}
if (!this.getPreference('tts', 'local-openai-tts_model')) {
this.updatePreference('tts', 'local-openai-tts_model', 'tts-1');
}
if (!this.getPreference('tts', 'local-openai-tts_timeout_ms')) {
this.updatePreference('tts', 'local-openai-tts_timeout_ms', 60000);
}
} }
/** /**
@@ -895,6 +1071,7 @@ class OptionsUIModule extends BaseModule {
this.renderProviderStatuses(); this.renderProviderStatuses();
}); });
this.updateApiSettingsVisibility(value); this.updateApiSettingsVisibility(value);
this.updateVoiceControlVisibility(value);
} else if (key === 'voice') { } else if (key === 'voice') {
ttsFactory.configure({ voice: value }); ttsFactory.configure({ voice: value });
} else if (key === 'speed') { } else if (key === 'speed') {
@@ -919,6 +1096,24 @@ class OptionsUIModule extends BaseModule {
const provider = key.replace('_api_url', ''); const provider = key.replace('_api_url', '');
this.dispatchApiChangeEvent('api:urlChanged', provider, 'url', value); this.dispatchApiChangeEvent('api:urlChanged', provider, 'url', value);
ttsFactory.refreshHandlerStatus(provider).then(() => this.renderProviderStatuses()); ttsFactory.refreshHandlerStatus(provider).then(() => this.renderProviderStatuses());
} else if (key.endsWith('_voice')) {
const provider = key.replace('_voice', '');
const handler = typeof ttsFactory.getHandler === 'function' ? ttsFactory.getHandler(provider) : null;
if (handler && typeof handler.setVoiceOptions === 'function') {
handler.setVoiceOptions({ voice: value });
}
if (ttsFactory.activeHandler === provider) {
ttsFactory.voice = value;
}
} else if (key.endsWith('_model')) {
const provider = key.replace('_model', '');
const handler = typeof ttsFactory.getHandler === 'function' ? ttsFactory.getHandler(provider) : null;
if (handler && typeof handler.setVoiceOptions === 'function') {
handler.setVoiceOptions({ model: value });
}
if (provider === 'openai-tts') {
this.populateVoices();
}
} }
if (key === 'speed' && this.elements.ttsSpeed) { if (key === 'speed' && this.elements.ttsSpeed) {
this.updateSpeedDisplay(); this.updateSpeedDisplay();
+41 -5
View File
@@ -35,10 +35,20 @@ class PersistenceManagerModule extends BaseModule {
speed: 1.0, speed: 1.0,
language: 'en_US', language: 'en_US',
voice: '', voice: '',
'browser-tts_timeout_ms': 60000,
'kokoro-tts_timeout_ms': 60000,
'elevenlabs-tts_api_key': '', 'elevenlabs-tts_api_key': '',
'elevenlabs-tts_api_url': 'https://api.elevenlabs.io/v1', 'elevenlabs-tts_api_url': 'https://api.elevenlabs.io/v1',
'elevenlabs-tts_timeout_ms': 60000,
'openai-tts_api_key': '', 'openai-tts_api_key': '',
'openai-tts_api_url': 'https://api.openai.com/v1' 'openai-tts_api_url': 'https://api.openai.com/v1',
'openai-tts_model': 'tts-1-hd',
'openai-tts_timeout_ms': 60000,
'local-openai-tts_api_key': '',
'local-openai-tts_api_url': 'http://localhost:8000/v1',
'local-openai-tts_voice': 'alloy',
'local-openai-tts_model': 'tts-1',
'local-openai-tts_timeout_ms': 60000
}, },
audio: { audio: {
masterVolume: 1.0, masterVolume: 1.0,
@@ -629,13 +639,39 @@ class PersistenceManagerModule extends BaseModule {
// Check if it's a range transformer in format 'range:min,max' // Check if it's a range transformer in format 'range:min,max'
if (element.dataset.prefTransform === 'centered-speed') { if (element.dataset.prefTransform === 'centered-speed') {
transformer = { transformer = {
toElement: (value) => Math.round(((Number(value) || 1) * 50) + 50), toElement: (value) => Math.round(Math.max(0.5, Math.min(2.0, Number(value) || 1)) * 100),
toPreference: (value) => Math.max(0.5, Math.min(2.0, (parseInt(value, 10) - 50) / 50)) toPreference: (value) => {
const percent = parseInt(value, 10);
return Math.max(0.5, Math.min(2.0, (Number.isFinite(percent) ? percent : 100) / 100));
}
}; };
} else if (element.dataset.prefTransform === 'multiplier-percent') { } else if (element.dataset.prefTransform === 'multiplier-percent') {
transformer = { transformer = {
toElement: (value) => Math.round((Number(value) || 1) * 100), toElement: (value) => Math.round(Math.max(0.5, Math.min(2.0, Number(value) || 1)) * 100),
toPreference: (value) => Math.max(0.25, Math.min(4.0, parseInt(value, 10) / 100)) toPreference: (value) => {
const percent = parseInt(value, 10);
return Math.max(0.5, Math.min(2.0, (Number.isFinite(percent) ? percent : 100) / 100));
}
};
} else if (element.dataset.prefTransform.startsWith('integer:')) {
const rangeValues = element.dataset.prefTransform.substring(8).split(',');
const min = Number.parseInt(rangeValues[0], 10);
const max = Number.parseInt(rangeValues[1], 10);
transformer = {
toElement: (value) => Number.parseInt(value, 10),
toPreference: (value) => {
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed)) {
return Number.isFinite(min) ? min : 0;
}
if (Number.isFinite(min) && parsed < min) {
return min;
}
if (Number.isFinite(max) && parsed > max) {
return max;
}
return parsed;
}
}; };
} else if (element.dataset.prefTransform.startsWith('range:')) { } else if (element.dataset.prefTransform.startsWith('range:')) {
const rangeValues = element.dataset.prefTransform.substring(6).split(','); const rangeValues = element.dataset.prefTransform.substring(6).split(',');
+45 -7
View File
@@ -45,6 +45,8 @@ class SentenceQueueModule extends BaseModule {
'prepareSpeechMetadata', 'prepareSpeechMetadata',
'preloadAssetsForItem', 'preloadAssetsForItem',
'normalizeTtsText', 'normalizeTtsText',
'getConfiguredTtsGenerationTimeoutMs',
'normalizeTtsGenerationTimeoutMs',
'runTtsPreloadWithTimeout', 'runTtsPreloadWithTimeout',
'cancelBlockingGeneration', 'cancelBlockingGeneration',
'cancelGenerationRequests', 'cancelGenerationRequests',
@@ -89,19 +91,25 @@ class SentenceQueueModule extends BaseModule {
const persistenceManager = this.getModule('persistence-manager'); const persistenceManager = this.getModule('persistence-manager');
if (persistenceManager && typeof persistenceManager.getPreference === 'function') { if (persistenceManager && typeof persistenceManager.getPreference === 'function') {
this.autoplay = persistenceManager.getPreference('app', 'autoplay', true) !== false; this.autoplay = persistenceManager.getPreference('app', 'autoplay', true) !== false;
this.ttsGenerationTimeoutMs = this.getConfiguredTtsGenerationTimeoutMs();
} }
this.addEventListener(document, 'preference-updated', (event) => { this.addEventListener(document, 'preference-updated', (event) => {
const { category, key, value } = event.detail || {}; const { category, key, value } = event.detail || {};
if (category === 'app' && key === 'autoplay') { if (category === 'app' && key === 'autoplay') {
this.autoplay = value !== false; this.autoplay = value !== false;
} }
if (category === 'tts' && (key === 'preferred_handler' || key.endsWith('_timeout_ms'))) {
this.ttsGenerationTimeoutMs = this.getConfiguredTtsGenerationTimeoutMs();
}
}); });
this.addEventListener(document, 'story:input-mode', (event) => { this.addEventListener(document, 'story:input-mode', (event) => {
this.inputMode = ['text', 'choice', 'end'].includes(event.detail) ? event.detail : 'text'; this.inputMode = ['text', 'choice', 'end'].includes(event.detail) ? event.detail : 'text';
}); });
this.addEventListener(document, 'ui:command', (event) => { this.addEventListener(document, 'ui:command', (event) => {
if (event.detail?.type === 'continue') { if (event.detail?.type === 'continue') {
if (event.detail?.source !== 'display-clear') {
this.lastContinueAt = performance.now(); this.lastContinueAt = performance.now();
}
this.cancelBlockingGeneration('user-fast-forward', { this.cancelBlockingGeneration('user-fast-forward', {
minWaitMs: USER_CANCEL_BLOCKING_WAIT_MIN_MS minWaitMs: USER_CANCEL_BLOCKING_WAIT_MIN_MS
}); });
@@ -305,11 +313,35 @@ class SentenceQueueModule extends BaseModule {
.trim(); .trim();
} }
getConfiguredTtsGenerationTimeoutMs() {
const persistenceManager = this.getModule('persistence-manager');
if (!persistenceManager || typeof persistenceManager.getPreference !== 'function') {
return TTS_GENERATION_TIMEOUT_MS;
}
const preferredHandler = persistenceManager.getPreference('tts', 'preferred_handler', 'none');
const providerTimeout = preferredHandler && preferredHandler !== 'none'
? persistenceManager.getPreference('tts', `${preferredHandler}_timeout_ms`)
: undefined;
const genericTimeout = persistenceManager.getPreference('tts', 'generation_timeout_ms');
return this.normalizeTtsGenerationTimeoutMs(providerTimeout ?? genericTimeout ?? TTS_GENERATION_TIMEOUT_MS);
}
normalizeTtsGenerationTimeoutMs(value) {
const timeout = Number(value);
if (!Number.isFinite(timeout)) {
return TTS_GENERATION_TIMEOUT_MS;
}
return Math.max(1000, Math.min(600000, Math.round(timeout)));
}
runTtsPreloadWithTimeout(ttsFactory, text, context = {}) { runTtsPreloadWithTimeout(ttsFactory, text, context = {}) {
const sentenceId = context.sentenceId || context.id || `tts-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const sentenceId = context.sentenceId || context.id || `tts-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const requestId = `${sentenceId}:${context.prefetch ? 'prefetch' : 'blocking'}:${Date.now()}`; const requestId = `${sentenceId}:${context.prefetch ? 'prefetch' : 'blocking'}:${Date.now()}`;
const controller = new AbortController(); const controller = new AbortController();
const startedAt = performance.now(); const startedAt = performance.now();
const timeoutMs = this.getConfiguredTtsGenerationTimeoutMs();
return new Promise((resolve) => { return new Promise((resolve) => {
let settled = false; let settled = false;
@@ -324,12 +356,12 @@ class SentenceQueueModule extends BaseModule {
const timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {
console.warn('SentenceQueue: TTS generation timed out; continuing without audio', { console.warn('SentenceQueue: TTS generation timed out; continuing without audio', {
sentenceId, sentenceId,
timeoutMs: this.ttsGenerationTimeoutMs, timeoutMs,
textPreview: text.slice(0, 120) textPreview: text.slice(0, 120)
}); });
controller.abort('tts-generation-timeout'); controller.abort('tts-generation-timeout');
finish({ success: false, reason: 'tts_generation_timeout', timedOut: true }); finish({ success: false, reason: 'tts_generation_timeout', timedOut: true });
}, this.ttsGenerationTimeoutMs); }, timeoutMs);
this.generationRequests.set(requestId, { this.generationRequests.set(requestId, {
controller, controller,
@@ -340,7 +372,10 @@ class SentenceQueueModule extends BaseModule {
finish finish
}); });
Promise.resolve(ttsFactory.preloadSpeech(text, { signal: controller.signal })) Promise.resolve(ttsFactory.preloadSpeech(text, {
signal: controller.signal,
ttsInstructions: Array.isArray(context.ttsInstructions) ? context.ttsInstructions : []
}))
.then(result => finish(result || { success: false, reason: 'empty_tts_result' })) .then(result => finish(result || { success: false, reason: 'empty_tts_result' }))
.catch(error => { .catch(error => {
if (controller.signal.aborted) { if (controller.signal.aborted) {
@@ -426,7 +461,10 @@ class SentenceQueueModule extends BaseModule {
let speedMultiplier = 1.0; let speedMultiplier = 1.0;
const ttsFactory = this.getModule('tts-factory'); const ttsFactory = this.getModule('tts-factory');
if (ttsFactory) { if (ttsFactory) {
speedMultiplier = Number.isFinite(ttsFactory.speed) ? Math.max(0.25, ttsFactory.speed) : 1.0; const configuredSpeed = Number(ttsFactory.speed);
speedMultiplier = Number.isFinite(configuredSpeed)
? Math.max(0.5, Math.min(2.0, configuredSpeed))
: 1.0;
} }
// Calculate estimated duration in milliseconds // Calculate estimated duration in milliseconds
@@ -486,6 +524,7 @@ class SentenceQueueModule extends BaseModule {
sentenceId: id, sentenceId: id,
blockId: metadata.blockId ?? null, blockId: metadata.blockId ?? null,
turnId: metadata.turnId ?? null, turnId: metadata.turnId ?? null,
ttsInstructions: Array.isArray(metadata.ttsInstructions) ? metadata.ttsInstructions : [],
blocking: true blocking: true
}); });
@@ -501,6 +540,7 @@ class SentenceQueueModule extends BaseModule {
paragraphIndex: metadata.paragraphIndex ?? null, paragraphIndex: metadata.paragraphIndex ?? null,
layoutText: metadata.layoutText || text, layoutText: metadata.layoutText || text,
glossaryEntries: Array.isArray(metadata.glossaryEntries) ? metadata.glossaryEntries : [], glossaryEntries: Array.isArray(metadata.glossaryEntries) ? metadata.glossaryEntries : [],
ttsInstructions: Array.isArray(metadata.ttsInstructions) ? metadata.ttsInstructions : [],
isFirstParagraphInChapter: Boolean(metadata.isFirstParagraphInChapter), isFirstParagraphInChapter: Boolean(metadata.isFirstParagraphInChapter),
role: metadata.role || (metadata.type === 'heading' ? 'chapter-heading' : 'body'), role: metadata.role || (metadata.type === 'heading' ? 'chapter-heading' : 'body'),
dropCap: Boolean(metadata.dropCap), dropCap: Boolean(metadata.dropCap),
@@ -753,9 +793,6 @@ class SentenceQueueModule extends BaseModule {
if (this.lastContinueAt >= (sentence.playbackStartedAt || 0)) { if (this.lastContinueAt >= (sentence.playbackStartedAt || 0)) {
return false; return false;
} }
if (this.inputMode === 'choice') {
return false;
}
return this.sentenceQueue.length > 1; return this.sentenceQueue.length > 1;
} }
@@ -848,6 +885,7 @@ class SentenceQueueModule extends BaseModule {
sentenceId: nextItem.id, sentenceId: nextItem.id,
blockId: nextItem.blockId ?? null, blockId: nextItem.blockId ?? null,
turnId: nextItem.turnId ?? null, turnId: nextItem.turnId ?? null,
ttsInstructions: Array.isArray(nextItem.ttsInstructions) ? nextItem.ttsInstructions : [],
queueIndex: index, queueIndex: index,
prefetch: true, prefetch: true,
blocking: false blocking: false
+8 -3
View File
@@ -291,12 +291,13 @@ class SocketClientModule extends BaseModule {
} }
} }
await this.storeAndQueueBlocks(turnBlocks);
const choices = Array.isArray(data.choices) ? data.choices : []; const choices = Array.isArray(data.choices) ? data.choices : [];
const inputMode = data.inputMode || (choices.length > 0 ? 'choice' : 'none'); const inputMode = data.inputMode || (choices.length > 0 ? 'choice' : 'none');
this.dispatchChoices(choices); this.dispatchChoices(choices);
this.dispatchInputMode(inputMode); this.dispatchInputMode(inputMode);
await this.storeAndQueueBlocks(turnBlocks);
document.dispatchEvent(new CustomEvent('story:turn-complete', { document.dispatchEvent(new CustomEvent('story:turn-complete', {
detail: { turnId, turn: data, choices, inputMode } detail: { turnId, turn: data, choices, inputMode }
})); }));
@@ -392,6 +393,9 @@ class SocketClientModule extends BaseModule {
const glossaryEntries = markupParser && typeof markupParser.extractGlossaryTags === 'function' const glossaryEntries = markupParser && typeof markupParser.extractGlossaryTags === 'function'
? markupParser.extractGlossaryTags(tags) ? markupParser.extractGlossaryTags(tags)
: []; : [];
const ttsInstructions = markupParser && typeof markupParser.extractTtsInstructionTags === 'function'
? markupParser.extractTtsInstructionTags(tags)
: [];
const cueTags = tags.filter(tag => this.isTimedCueTag(tag)); const cueTags = tags.filter(tag => this.isTimedCueTag(tag));
const deferredTags = tags.filter(tag => this.isDeferredPopupTag(tag)); const deferredTags = tags.filter(tag => this.isDeferredPopupTag(tag));
const immediateTags = tags.filter(tag => const immediateTags = tags.filter(tag =>
@@ -433,6 +437,7 @@ class SocketClientModule extends BaseModule {
text, text,
layoutText, layoutText,
glossaryEntries, glossaryEntries,
ttsInstructions,
cueMarkers, cueMarkers,
deferredTags: [ deferredTags: [
...(Array.isArray(pending.deferredTags) ? pending.deferredTags : []), ...(Array.isArray(pending.deferredTags) ? pending.deferredTags : []),
@@ -503,7 +508,7 @@ class SocketClientModule extends BaseModule {
isRenderMetadataTag(tag) { isRenderMetadataTag(tag) {
const key = String(tag?.key || '').toLowerCase(); const key = String(tag?.key || '').toLowerCase();
return ['gloss'].includes(key); return key === 'gloss' || key === 'tts' || key.startsWith('tts-');
} }
isDeferredPopupTag(tag) { isDeferredPopupTag(tag) {
+32 -9
View File
@@ -18,7 +18,8 @@ class TTSFactoryModule extends BaseModule {
'browser-tts', // Browser TTS handler 'browser-tts', // Browser TTS handler
'kokoro-tts', // Kokoro TTS handler 'kokoro-tts', // Kokoro TTS handler
'elevenlabs-tts',// ElevenLabs TTS handler 'elevenlabs-tts',// ElevenLabs TTS handler
'openai-tts' // OpenAI TTS handler 'openai-tts', // OpenAI TTS handler
'local-openai-tts' // Local OpenAI-compatible TTS handler
]; ];
this.handlers = {}; this.handlers = {};
this.initStatus = {}; this.initStatus = {};
@@ -356,7 +357,7 @@ class TTSFactoryModule extends BaseModule {
} }
// Add placeholder entries for important API handlers that might not be registered yet // Add placeholder entries for important API handlers that might not be registered yet
const apiHandlerIds = ['elevenlabs-tts', 'openai-tts']; const apiHandlerIds = ['elevenlabs-tts', 'openai-tts', 'local-openai-tts'];
for (const id of apiHandlerIds) { for (const id of apiHandlerIds) {
// Only add if not already in the list // Only add if not already in the list
if (!this.handlers[id] && !availableHandlers.some(h => h.id === id)) { if (!this.handlers[id] && !availableHandlers.some(h => h.id === id)) {
@@ -407,10 +408,24 @@ class TTSFactoryModule extends BaseModule {
'voice': '', // Empty default - will be selected based on handler 'voice': '', // Empty default - will be selected based on handler
'language': 'en_US', // Legacy stored value; game metadata now owns active TTS language 'language': 'en_US', // Legacy stored value; game metadata now owns active TTS language
'volume': 1.0, // Default volume 'volume': 1.0, // Default volume
'browser-tts_timeout_ms': 60000,
'kokoro-tts_timeout_ms': 60000,
'elevenlabs_api_key': '', // Empty API key by default 'elevenlabs_api_key': '', // Empty API key by default
'elevenlabs_api_url': 'https://api.elevenlabs.io/v1', // Default ElevenLabs API URL 'elevenlabs_api_url': 'https://api.elevenlabs.io/v1', // Default ElevenLabs API URL
'openai_api_key': '', // Empty API key by default 'openai_api_key': '', // Empty API key by default
'openai_api_url': 'https://api.openai.com/v1' // Default OpenAI API URL 'openai_api_url': 'https://api.openai.com/v1', // Default OpenAI API URL
'elevenlabs-tts_api_key': '',
'elevenlabs-tts_api_url': 'https://api.elevenlabs.io/v1',
'elevenlabs-tts_timeout_ms': 60000,
'openai-tts_api_key': '',
'openai-tts_api_url': 'https://api.openai.com/v1',
'openai-tts_model': 'tts-1-hd',
'openai-tts_timeout_ms': 60000,
'local-openai-tts_api_key': '',
'local-openai-tts_api_url': 'http://localhost:8000/v1',
'local-openai-tts_voice': 'alloy',
'local-openai-tts_model': 'tts-1',
'local-openai-tts_timeout_ms': 60000
}; };
// Ensure all defaults are set in persistence if they don't exist // Ensure all defaults are set in persistence if they don't exist
@@ -475,7 +490,8 @@ class TTSFactoryModule extends BaseModule {
{ id: 'kokoro-tts', displayName: 'Kokoro TTS' }, { id: 'kokoro-tts', displayName: 'Kokoro TTS' },
{ id: 'browser-tts', displayName: 'Browser TTS' }, { id: 'browser-tts', displayName: 'Browser TTS' },
{ id: 'elevenlabs-tts', displayName: 'ElevenLabs TTS' }, { id: 'elevenlabs-tts', displayName: 'ElevenLabs TTS' },
{ id: 'openai-tts', displayName: 'OpenAI TTS' } { id: 'openai-tts', displayName: 'OpenAI TTS' },
{ id: 'local-openai-tts', displayName: 'Local OpenAI TTS' }
]; ];
// Register each handler // Register each handler
@@ -780,7 +796,7 @@ class TTSFactoryModule extends BaseModule {
} }
// Check if we have this speech cached // Check if we have this speech cached
const hash = await this.generateSpeechHash(text); const hash = await this.generateSpeechHash(text, options);
const cached = await this.getCachedSpeech(hash); const cached = await this.getCachedSpeech(hash);
if (cached && cached.success) { if (cached && cached.success) {
@@ -845,7 +861,7 @@ class TTSFactoryModule extends BaseModule {
try { try {
// Generate a hash for this speech request // Generate a hash for this speech request
const hash = await this.generateSpeechHash(text); const hash = await this.generateSpeechHash(text, options);
// Check if we have this speech cached // Check if we have this speech cached
const cached = await this.getCachedSpeech(hash); const cached = await this.getCachedSpeech(hash);
@@ -1097,6 +1113,7 @@ class TTSFactoryModule extends BaseModule {
getHandlerStatusMessage(id, handler) { getHandlerStatusMessage(id, handler) {
if (!handler) return 'Not registered'; if (!handler) return 'Not registered';
if (handler.isReady === true) return 'Ready'; if (handler.isReady === true) return 'Ready';
if (handler.unsupportedReason) return handler.unsupportedReason;
if (id === 'kokoro-tts') return handler.state === 'INITIALIZING' ? 'Loading model' : 'Not loaded'; if (id === 'kokoro-tts') return handler.state === 'INITIALIZING' ? 'Loading model' : 'Not loaded';
if (handler.apiKey === '') return 'API key missing'; if (handler.apiKey === '') return 'API key missing';
if (handler.apiKey && handler.isReady !== true) return 'API unavailable or invalid settings'; if (handler.apiKey && handler.isReady !== true) return 'API unavailable or invalid settings';
@@ -1234,7 +1251,7 @@ class TTSFactoryModule extends BaseModule {
let generationStarted = false; let generationStarted = false;
try { try {
// Generate a hash for this speech request // Generate a hash for this speech request
hash = await this.generateSpeechHash(text); hash = await this.generateSpeechHash(text, options);
// Check if we have this audio in cache // Check if we have this audio in cache
const cachedData = await this.getCachedSpeech(hash); const cachedData = await this.getCachedSpeech(hash);
@@ -1286,17 +1303,23 @@ class TTSFactoryModule extends BaseModule {
* @param {string} text - Text to generate hash for * @param {string} text - Text to generate hash for
* @returns {Promise<string>} - Hash string * @returns {Promise<string>} - Hash string
*/ */
async generateSpeechHash(text) { async generateSpeechHash(text, options = {}) {
const handler = this.getActiveHandler(); const handler = this.getActiveHandler();
const provider = this.activeHandler || 'none'; const provider = this.activeHandler || 'none';
const voiceInfo = this.getEffectiveVoiceId(handler); const voiceInfo = this.getEffectiveVoiceId(handler);
const model = handler?.voiceOptions?.model || handler?.model || '';
const speed = this.speed || 1.0; const speed = this.speed || 1.0;
const language = this.language || 'en-us'; const language = this.language || 'en-us';
const ttsInstruction = handler && typeof handler.getRequestInstructions === 'function'
? handler.getRequestInstructions(options)
: '';
const key = JSON.stringify({ const key = JSON.stringify({
provider, provider,
voice: voiceInfo, voice: voiceInfo,
model,
speed, speed,
language, language,
ttsInstruction,
text text
}); });
@@ -1933,7 +1956,7 @@ class TTSFactoryModule extends BaseModule {
const handler = this.handlers[id]; const handler = this.handlers[id];
const isInitialized = !!this.initStatus[id]; const isInitialized = !!this.initStatus[id];
const isReady = handler && handler.isReady; const isReady = handler && handler.isReady;
const isApiHandler = ['elevenlabs', 'openai', 'kokoro'].includes(id); const isApiHandler = ['elevenlabs-tts', 'openai-tts', 'local-openai-tts', 'kokoro-tts'].includes(id);
console.log(`Handler ID: ${id}`); console.log(`Handler ID: ${id}`);
console.log(` - Handler Exists: ${!!handler}`); console.log(` - Handler Exists: ${!!handler}`);
+6 -7
View File
@@ -387,12 +387,12 @@ class UIControllerModule extends BaseModule {
sliderValueFromSpeed(speed) { sliderValueFromSpeed(speed) {
const value = Number.isFinite(Number(speed)) ? Number(speed) : 1; const value = Number.isFinite(Number(speed)) ? Number(speed) : 1;
return Math.round((Math.max(0.5, Math.min(2.0, value)) * 50) + 50); return Math.round(Math.max(0.5, Math.min(2.0, value)) * 100);
} }
speedFromSliderValue(value) { speedFromSliderValue(value) {
const sliderValue = Number.isFinite(Number(value)) ? Number(value) : 50; const sliderValue = Number.isFinite(Number(value)) ? Number(value) : 100;
return Math.max(0.5, Math.min(2.0, (sliderValue - 50) / 50)); return Math.max(0.5, Math.min(2.0, sliderValue / 100));
} }
bindTopControls() { bindTopControls() {
@@ -453,14 +453,13 @@ class UIControllerModule extends BaseModule {
if (speedSlider && speedSlider.dataset.uiControllerBound !== 'true') { if (speedSlider && speedSlider.dataset.uiControllerBound !== 'true') {
speedSlider.dataset.uiControllerBound = 'true'; speedSlider.dataset.uiControllerBound = 'true';
speedSlider.min = speedSlider.min || '50'; speedSlider.min = '50';
speedSlider.max = speedSlider.max || '150'; speedSlider.max = '200';
speedSlider.addEventListener('input', (event) => { speedSlider.addEventListener('input', (event) => {
const persistenceManager = this.getModule('persistence-manager');
const speed = this.speedFromSliderValue(event.target.value); const speed = this.speedFromSliderValue(event.target.value);
document.dispatchEvent(new CustomEvent('animation:speed:change', { document.dispatchEvent(new CustomEvent('animation:speed:change', {
detail: { speed: 1 } detail: { speed }
})); }));
document.dispatchEvent(new CustomEvent('tts:speed:change', { document.dispatchEvent(new CustomEvent('tts:speed:change', {
+1 -1
View File
@@ -386,7 +386,7 @@ class UIDisplayHandlerModule extends BaseModule {
controls.innerHTML = ` controls.innerHTML = `
<a id="speech"></a> <a id="speech"></a>
<a id="autoplay"></a> <a id="autoplay"></a>
<span><a id="speed_reset"><span id="speed_label"></span></a><input type="range" min="50" max="150" value="100" id="speed" name="speed" /></span> <span><a id="speed_reset"><span id="speed_label"></span></a><input type="range" min="50" max="200" value="100" id="speed" name="speed" /></span>
<a id="rewind"></a> <a id="rewind"></a>
<a id="save"></a> <a id="save"></a>
<a id="reload" disabled="disabled"></a> <a id="reload" disabled="disabled"></a>
+4
View File
@@ -47,8 +47,12 @@
"options.enableMusicDucking": "Musikabsenkung einschalten", "options.enableMusicDucking": "Musikabsenkung einschalten",
"options.elevenLabsSettings": "ElevenLabs API-Einstellungen", "options.elevenLabsSettings": "ElevenLabs API-Einstellungen",
"options.openAiSettings": "OpenAI API-Einstellungen", "options.openAiSettings": "OpenAI API-Einstellungen",
"options.localOpenAiSettings": "Lokale OpenAI API-Einstellungen",
"options.optionalApiKey": "API-Schluessel (optional)",
"options.apiKey": "API-Schlüssel", "options.apiKey": "API-Schlüssel",
"options.apiUrl": "API-URL", "options.apiUrl": "API-URL",
"options.model": "Modell",
"options.requestTimeoutMs": "Anfrage-Timeout (ms)",
"credits.button": "Credits", "credits.button": "Credits",
"credits.buttonTitle": "Mitwirkende und Lizenzen anzeigen", "credits.buttonTitle": "Mitwirkende und Lizenzen anzeigen",
"credits.title": "Mitwirkende und Lizenzen", "credits.title": "Mitwirkende und Lizenzen",
+4
View File
@@ -47,8 +47,12 @@
"options.enableMusicDucking": "Enable music ducking", "options.enableMusicDucking": "Enable music ducking",
"options.elevenLabsSettings": "ElevenLabs API Settings", "options.elevenLabsSettings": "ElevenLabs API Settings",
"options.openAiSettings": "OpenAI API Settings", "options.openAiSettings": "OpenAI API Settings",
"options.localOpenAiSettings": "Local OpenAI API Settings",
"options.apiKey": "API Key", "options.apiKey": "API Key",
"options.optionalApiKey": "API Key (optional)",
"options.apiUrl": "API URL", "options.apiUrl": "API URL",
"options.model": "Model",
"options.requestTimeoutMs": "Request timeout (ms)",
"credits.button": "credits", "credits.button": "credits",
"credits.buttonTitle": "Show credits and third-party licenses", "credits.buttonTitle": "Show credits and third-party licenses",
"credits.title": "Credits and Licenses", "credits.title": "Credits and Licenses",
+38 -2
View File
@@ -23,13 +23,14 @@
<div class="option-item"> <div class="option-item">
<label>Voice:</label> <label>Voice:</label>
<select id="tts-voice" data-pref-bind="tts.voice"></select> <select id="tts-voice" data-pref-bind="tts.voice"></select>
<input type="text" id="local-openai-voice" data-pref-bind="tts.local-openai-tts_voice" placeholder="alloy" style="display: none;">
</div> </div>
<div class="option-item"> <div class="option-item">
<label>Speech:</label> <label>Speech:</label>
<span class="slider-value">100%</span> <span class="slider-value">100%</span>
<input type="range" id="tts-speed" min="50" max="200" value="100" <input type="range" id="tts-speed" min="50" max="200" value="100"
data-pref-bind="app.speed" data-pref-transform="range:0.5,2.0"> data-pref-bind="tts.speed" data-pref-transform="multiplier-percent">
</div> </div>
<!-- API Settings --> <!-- API Settings -->
@@ -50,7 +51,7 @@
</div> </div>
<!-- OpenAI Settings --> <!-- OpenAI Settings -->
<div class="api-settings openai-settings" style="display: none;"> <div class="api-settings openai-tts-settings" style="display: none;">
<h3>OpenAI API Settings</h3> <h3>OpenAI API Settings</h3>
<div class="option-item"> <div class="option-item">
@@ -62,6 +63,41 @@
<label>API URL:</label> <label>API URL:</label>
<input type="text" id="openai-api-url" data-pref-bind="tts.openai-tts_api_url"> <input type="text" id="openai-api-url" data-pref-bind="tts.openai-tts_api_url">
</div> </div>
<div class="option-item">
<label>Model:</label>
<select id="openai-model" data-pref-bind="tts.openai-tts_model">
<option value="tts-1">TTS-1</option>
<option value="tts-1-hd">TTS-1 HD</option>
<option value="gpt-4o-mini-tts">GPT-4o mini TTS</option>
</select>
</div>
</div>
<!-- Local OpenAI-compatible Settings -->
<div class="api-settings local-openai-tts-settings" style="display: none;">
<h3>Local OpenAI API Settings</h3>
<div class="option-item">
<label>API Key (optional):</label>
<input type="password" id="local-openai-api-key" data-pref-bind="tts.local-openai-tts_api_key">
</div>
<div class="option-item">
<label>API URL:</label>
<input type="text" id="local-openai-api-url" data-pref-bind="tts.local-openai-tts_api_url">
</div>
<div class="option-item">
<label>Model:</label>
<input type="text" id="local-openai-model" data-pref-bind="tts.local-openai-tts_model" placeholder="tts-1">
</div>
<div class="option-item">
<label>Request timeout (ms):</label>
<input type="number" id="local-openai-timeout-ms" min="1000" max="600000" step="1000"
data-pref-bind="tts.local-openai-tts_timeout_ms" data-pref-transform="integer:1000,600000">
</div>
</div> </div>
</div> </div>
</div> </div>
+78 -2
View File
@@ -161,6 +161,7 @@ export class InkEngine {
const paragraphs: TurnResult['paragraphs'] = []; const paragraphs: TurnResult['paragraphs'] = [];
const globalTags: StoryTag[] = []; const globalTags: StoryTag[] = [];
const turnTags: StoryTag[] = []; const turnTags: StoryTag[] = [];
let pendingParagraphTags: StoryTag[] = [];
while (this.story.canContinue) { while (this.story.canContinue) {
const rawText = this.story.Continue(); const rawText = this.story.Continue();
@@ -173,10 +174,23 @@ export class InkEngine {
.forEach((tag) => globalTags.push(tag)); .forEach((tag) => globalTags.push(tag));
if (text) { if (text) {
paragraphs.push({ text, tags }); const paragraphTags = this.reassignTrailingGlossTags(text, [...pendingParagraphTags, ...tags], paragraphs);
pendingParagraphTags = [];
paragraphs.push({ text, tags: paragraphTags });
} else { } else {
tags.forEach((tag) => globalTags.push(tag)); const paragraphTags = this.reassignTrailingGlossTags('', tags, paragraphs);
paragraphTags.forEach((tag) => {
if (this.isParagraphScopedTag(tag)) {
pendingParagraphTags.push(tag);
} else {
globalTags.push(tag);
} }
});
}
}
if (pendingParagraphTags.length > 0) {
globalTags.push(...pendingParagraphTags);
pendingParagraphTags = [];
} }
const choices = this.story.currentChoices.map((choice): ChoiceResult => { const choices = this.story.currentChoices.map((choice): ChoiceResult => {
@@ -234,6 +248,68 @@ export class InkEngine {
}; };
} }
private isParagraphScopedTag(tag: StoryTag): boolean {
const key = String(tag?.key || '').toLowerCase();
return ['chapter', 'heading', 'section', 'textblock', 'image', 'music', 'sfx', 'sound', 'audio', 'gloss', 'tts']
.includes(key) || key.startsWith('tts-');
}
private reassignTrailingGlossTags(
text: string,
tags: StoryTag[],
paragraphs: TurnResult['paragraphs'],
): StoryTag[] {
if (!Array.isArray(tags) || tags.length === 0) return [];
const previous = paragraphs.length > 0 ? paragraphs[paragraphs.length - 1] : null;
if (!previous) return tags;
const currentText = this.normalizeGlossMatchText(text);
const previousText = this.normalizeGlossMatchText(previous.text);
const remainingTags: StoryTag[] = [];
tags.forEach((tag) => {
if (tag.key === 'tts' || tag.key.startsWith('tts-')) {
if (!currentText) {
previous.tags.push(tag);
} else {
remainingTags.push(tag);
}
return;
}
if (tag.key !== 'gloss') {
remainingTags.push(tag);
return;
}
const term = this.normalizeGlossMatchText(tag.value || '');
if (!term) {
remainingTags.push(tag);
return;
}
const matchesCurrent = currentText.includes(term);
const matchesPrevious = previousText.includes(term);
if (!matchesCurrent && matchesPrevious) {
previous.tags.push(tag);
} else {
remainingTags.push(tag);
}
});
return remainingTags;
}
private normalizeGlossMatchText(value: string): string {
return String(value || '')
.normalize('NFC')
.toLocaleLowerCase('de-DE')
.replace(/[.,;:!?()[\]{}"'„“”‚‘’»«]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
private getChoiceTags(choice: any): StoryTag[] { private getChoiceTags(choice: any): StoryTag[] {
const directTags = parseTags(choice?.tags || []); const directTags = parseTags(choice?.tags || []);
const previewTags = this.extractChoicePreviewTags(choice); const previewTags = this.extractChoicePreviewTags(choice);
+7 -1
View File
@@ -110,7 +110,13 @@ async function handleGameApi(
const engine = new InkEngine(getStoryPath()); const engine = new InkEngine(getStoryPath());
sessions.set(socket.id, engine); sessions.set(socket.id, engine);
socket.emit('narrativeResponse', engine.newGame()); socket.emit('narrativeResponse', engine.newGame());
return { success: true, result: true, running: true, canLoad: slots.size > 0 }; return {
success: true,
result: true,
running: true,
canLoad: slots.size > 0,
savedState: engine.saveGame(),
};
} }
case 'chooseChoice': case 'chooseChoice':