From 24ec2a92b56ca4296fe65b70524e36a8e1c67c2e Mon Sep 17 00:00:00 2001 From: Alexander Kassouni Date: Sat, 5 Sep 2026 22:43:52 -0700 Subject: phonemes --- docs/index.html | 2 +- docs/phonology.html | 42 ++++++++-- docs/playback.html | 2 +- docs/quirks.html | 2 +- docs/scheduling.html | 9 ++- src/main.rs | 2 +- src/phonemes/consonant.rs | 53 ++++++++++++ src/phonemes/digit.rs | 31 +++++++ src/phonemes/digraph.rs | 118 +++++++++++++++++++++++++++ src/phonemes/mod.rs | 11 +++ src/phonemes/phonemes.rs | 30 ++++++- src/phonemes/vowel.rs | 38 +++++++++ src/tokenizer/mod.rs | 2 +- src/tokenizer/tokens.rs | 202 +++++++++++++++++++++++++++++++++------------- 14 files changed, 472 insertions(+), 72 deletions(-) create mode 100644 src/phonemes/consonant.rs create mode 100644 src/phonemes/digit.rs create mode 100644 src/phonemes/digraph.rs create mode 100644 src/phonemes/vowel.rs diff --git a/docs/index.html b/docs/index.html index d6a0817..7017da5 100644 --- a/docs/index.html +++ b/docs/index.html @@ -60,7 +60,7 @@ entirely from the samples.

font renderer, and the every-other-frame cursor.
  • 4. The phonology engine - Na_VoiceSe in full: the 55-entry digraph table, short/long + Na_VoiceSe in full: the 53-entry digraph table, short/long vowel tables, silent e, magic e, and punctuation flush.
  • 5. Playback diff --git a/docs/phonology.html b/docs/phonology.html index ebbc872..075f3a2 100644 --- a/docs/phonology.html +++ b/docs/phonology.html @@ -47,8 +47,13 @@ rewritten.

    0. Lone mute

    if (a == 0x85 && b == 0xFF && c == 0xFF) return;
    -

    Returns before touching any state, so a lone punctuation mark does not disturb the -previous-character history.

    +

    sou_num_org, sou_num2_org and sou_num3_org are assigned +before this test, so they are overwritten; what survives is +sou_last_num / sou_last_num2, which are written further down. A lone +punctuation mark therefore leaves the digraph history intact, but still clobbers the +context bytes a pending mode 2 call will read — +sou_num3_org for its short-vowel lookup, sou_num_org for its dedup +comparison.

    1. Silent e (:2612)

    if (a == 'e' && b > 0x77 && class(last_num) == vowel && class(last_num2) == consonant) {
    @@ -71,6 +76,17 @@ are consumed by one sample.

    The digraph table — Sou_ConnectCheck (:2316)

    53 entries. All are two characters except you, the only rule that reads c; one pairs a letter with a space rather than another letter (i␣).

    +
    +i␣ is very nearly unreachable. Its b is 0x81, +which is also a punctuation-flush trigger — so rule 3 below sets r28 = 3 and +unconditionally reassigns a, discarding the 0x05. It survives only when +the previous call's b was also punctuation, which forces r28 = 1 +instead; 0x05 then falls through Sou_TanboinHenkan's +default: return a and reaches playback unchanged. Every other digraph has a letter +in b and so never meets rule 3 — for those, "r28 = 2 falls straight +through to playback" holds as written. +
    +
    ai→0x0F
    al→0x02
    an→0x39
    ar→0x16
    as→0x3A
    be→0x3B
    by→0x3C
    @@ -110,9 +126,23 @@ each one a recorded syllable.

    a = Sou_ConnectCheck(sou_last_num2, sou_num_org, sou_num2_org);
     if (a < 0x53 || a > 0x76) break;      // it merged, or wasn't a letter — leave it
     a = Sou_TanboinHenkan(a, b);
    -

    This gives the final letter of a word a chance to pair with the letter before it -instead of being dropped against the following space. Note the guard also drops the -whole branch if a > 0x77 before it starts.

    +

    The intent is to give the final letter of a word a chance to pair with the letter +before it instead of being dropped against the following space. What it does is narrower. +a is reloaded from sou_last_num2 — the previous call's +b — and if Sou_ConnectCheck finds no merge it returns that byte +unchanged, so Sou_TanboinHenkan speaks the previous letter a second time and the +word-final letter is never voiced at all.

    + +

    Worked example, cat␣. Window (c, a, t) doesn't merge, so +r28 = 1 plays hard c and queues a; the deferred call plays +a. Window (t, ␣, …) then hits rule 3, reloads +a = sou_last_num2 = 'a', fails to merge at, and plays a +again. The t makes no sound. The branch only pays off when the reloaded pair +does merge — then the merged code plays and the 0x53–0x76 +guard breaks out before Sou_TanboinHenkan can touch it.

    + +

    Note the guard also drops the whole branch if a > 0x77 before it starts, and +that nothing is queued on this path.

    4. Vowel length (:2658, for r28 == 1 or 4)

    if (class(a) == vowel && class(b) == consonant && c == 'e')
    @@ -168,7 +198,7 @@ with the first syllable of the spoken number: 0=ze(ro)
     get codes nothing else reaches. The digraph names here are just labels for shared
     samples — the recording itself presumably says the number.

    -

    The deferred second syllable — mode 2

    +

    The deferred second syllable — mode 2

    Sou_SpecialRoutine10 (:1708) runs once per game frame inside Na_GameFrame:

    diff --git a/docs/playback.html b/docs/playback.html index 1cf9464..73be9a2 100644 --- a/docs/playback.html +++ b/docs/playback.html @@ -33,7 +33,7 @@ into the phoneme path — the pitch and volume bumps below never apply to the pl CodeCharEffect 0x86clickNA_SE_BEBE, then mute 0x81 0x84 0x85space, newline, mutemute -0x82?mute, and pitch × 1.24 / 1.30 / 1.13 for sequence 243 / 244 / 245 +0x82?mute, and pitch × 1.24 / 1.30 / 1.13 for sou_now_voice_seq 1 / 2 / 3 — the small index Na_SpecChange sets alongside sequence 243 / 244 / 245, not the sequence number itself 0x83!mute, and volume × 1.15 anything elseplays; sets sou_now_boin (dead, see quirks) diff --git a/docs/quirks.html b/docs/quirks.html index bb860c9..c46fdb2 100644 --- a/docs/quirks.html +++ b/docs/quirks.html @@ -145,7 +145,7 @@ the config. See gating.

    Everything else in extraction.md §1 was re-verified against the current tree -and holds: the voice table, the 55 digraph entries, both vowel tables, the per-spec +and holds: the voice table, the 53 digraph entries, both vowel tables, the per-spec timbre values, and the spec→sequence→bank mapping.

    diff --git a/docs/scheduling.html b/docs/scheduling.html index 581dff6..07f1e52 100644 --- a/docs/scheduling.html +++ b/docs/scheduling.html @@ -111,9 +111,12 @@ volume adjustment is skipped entirely. (What happens for other values is not what it looks like.)

    It also contains one very specific special case: if the character is ♪ at exactly -normal scale in colour rgba(50, 60, 50, 255), the NPC id is forced to 0, -suppressing the melody. This is presumably a specific piece of dialogue where a -literal music note glyph should stay silent.

    +normal scale in colour rgba(50, 60, 50, 255) and the NPC id is non-zero, that id +is forced to 0. This does not silence it — Na_VoiceSe's ♪ escape +only bails on voice == 0xFA, and melody_seq_offset[0] / +melody_seq_size[0] are a real 0x120-byte entry. The effect is to play +the figure on instrument 0 rather than the speaker's own, presumably so one particular +line reads as a generic note instead of that character's timbre.

    End of message

    diff --git a/src/main.rs b/src/main.rs index d35753c..44d0887 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,8 +10,8 @@ struct Args { timbre: f64, } +mod phonemes; mod tokenizer; -use tokenizer::VoiceCode; fn main() { let _args = Args::parse(); diff --git a/src/phonemes/consonant.rs b/src/phonemes/consonant.rs new file mode 100644 index 0000000..bdf07ee --- /dev/null +++ b/src/phonemes/consonant.rs @@ -0,0 +1,53 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Consonant { + B, + CHard, + CSoft, + D, + F, + G, + H, + J, + K, + L, + M, + P, + Q, + R, + S, + T, + V, + W, + X, + Y, + Z, +} + +impl Consonant { + pub const fn code(self) -> u8 { + use Consonant::*; + match self { + B => 0x07, + CHard => 0x18, + CSoft => 0x23, + D => 0x09, + F => 0x11, + G => 0x12, + H => 0x00, + J => 0x17, + K => 0x18, + L => 0x19, + M => 0x1A, + P => 0x21, + Q => 0x18, + R => 0x22, + S => 0x23, + T => 0x26, + V => 0x2D, + W => 0x2E, + X => 0x31, + Y => 0x15, + Z => 0x30, + } + } +} diff --git a/src/phonemes/digit.rs b/src/phonemes/digit.rs new file mode 100644 index 0000000..a694016 --- /dev/null +++ b/src/phonemes/digit.rs @@ -0,0 +1,31 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Digit { + D0, + D1, + D2, + D3, + D4, + D5, + D6, + D7, + D8, + D9, +} + +impl Digit { + pub const fn code(self) -> u8 { + use Digit::*; + match self { + D0 => 0x32, + D1 => 0x33, + D2 => 0x26, + D3 => 0x27, + D4 => 0x34, + D5 => 0x35, + D6 => 0x36, + D7 => 0x37, + D8 => 0x10, + D9 => 0x38, + } + } +} diff --git a/src/phonemes/digraph.rs b/src/phonemes/digraph.rs new file mode 100644 index 0000000..c332520 --- /dev/null +++ b/src/phonemes/digraph.rs @@ -0,0 +1,118 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[allow(non_camel_case_types)] +pub enum Digraph { + AI, + AL, + AN, + AR, + AS, + BE, + BY, + CH, + DO, + EA, + ER, + FA, + FO, + GE, + GO, + HA, + HE, + HI, + HO, + I_, + IF, + IN, + IR, + IS, + IT, + KS, + ME, + MY, + NA, + NG, + NO, + OF, + OH, + OK, + ON, + OO, + OR, + OU, + OW, + OY, + RE, + SE, + SH, + SI, + SO, + SU, + TH, + TO, + UP, + WE, + WO, + YOU, + ZE, +} + +impl Digraph { + pub const fn code(self) -> u8 { + use Digraph::*; + match self { + AI => 0x0F, + AL => 0x02, + AN => 0x39, + AR => 0x16, + AS => 0x3A, + BE => 0x3B, + BY => 0x3C, + CH => 0x08, + DO => 0x3D, + EA => 0x15, + ER => 0x0D, + FA => 0x35, + FO => 0x34, + GE => 0x17, + GO => 0x3E, + HA => 0x3F, + HE => 0x40, + HI => 0x41, + HO => 0x42, + I_ => 0x05, + IF => 0x43, + IN => 0x44, + IR => 0x0C, + IS => 0x45, + IT => 0x46, + KS => 0x31, + ME => 0x47, + MY => 0x48, + NA => 0x38, + NG => 0x1C, + NO => 0x49, + OF => 0x4A, + OH => 0x4B, + OK => 0x4C, + ON => 0x4D, + OO => 0x2A, + OR => 0x2C, + OU => 0x20, + OW => 0x06, + OY => 0x1F, + RE => 0x0E, + SE => 0x37, + SH => 0x24, + SI => 0x36, + SO => 0x4F, + SU => 0x25, + TH => 0x28, + TO => 0x50, + UP => 0x51, + WE => 0x52, + WO => 0x33, + YOU => 0x77, + ZE => 0x32, + } + } +} diff --git a/src/phonemes/mod.rs b/src/phonemes/mod.rs index e69de29..3f0276e 100644 --- a/src/phonemes/mod.rs +++ b/src/phonemes/mod.rs @@ -0,0 +1,11 @@ +mod consonant; +mod digit; +mod digraph; +mod phonemes; +mod vowel; + +pub use consonant::Consonant; +pub use digit::Digit; +pub use digraph::Digraph; +pub use phonemes::Phoneme; +pub use vowel::{Vowel, VowelLen, VowelPhoneme}; diff --git a/src/phonemes/phonemes.rs b/src/phonemes/phonemes.rs index 9313c21..3ec5e56 100644 --- a/src/phonemes/phonemes.rs +++ b/src/phonemes/phonemes.rs @@ -1,6 +1,32 @@ -#[derive(Debug, PartialEq, Eq)] +use super::consonant::Consonant; +use super::digit::Digit; +use super::digraph::Digraph; +use super::vowel::VowelPhoneme; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Phoneme { - + Vowel(VowelPhoneme), + Consonant(Consonant), + Digit(Digit), + Digraph(Digraph), + Silent, + Raw(u8), } +impl Phoneme { + pub const fn code(self) -> u8 { + match self { + Phoneme::Vowel(v) => v.code(), + Phoneme::Consonant(c) => c.code(), + Phoneme::Digit(d) => d.code(), + Phoneme::Digraph(d) => d.code(), + Phoneme::Silent => 0x00, + Phoneme::Raw(n) => n, + } + } + pub const fn plays(self) -> bool { + let n = self.code(); + n != 0 && n <= 0x77 + } +} diff --git a/src/phonemes/vowel.rs b/src/phonemes/vowel.rs new file mode 100644 index 0000000..622185d --- /dev/null +++ b/src/phonemes/vowel.rs @@ -0,0 +1,38 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Vowel { + A, + I, + U, + E, + O, + N, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VowelLen { + Short, + Long, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct VowelPhoneme(pub Vowel, pub VowelLen); + +impl VowelPhoneme { + pub const fn code(self) -> u8 { + use Vowel::*; + use VowelLen::*; + match (self.0, self.1) { + (A, Short) => 0x01, + (A, Long) => 0x02, + (I, Short) => 0x14, + (I, Long) => 0x15, + (U, Short) => 0x29, + (U, Long) => 0x2A, + (E, Short) => 0x0A, + (E, Long) => 0x0E, + (O, Short) => 0x1D, + (O, Long) => 0x1E, + (N, _) => 0x1B, + } + } +} diff --git a/src/tokenizer/mod.rs b/src/tokenizer/mod.rs index 54ea9a3..1651f76 100644 --- a/src/tokenizer/mod.rs +++ b/src/tokenizer/mod.rs @@ -1,2 +1,2 @@ mod tokens; -pub use tokens::VoiceCode; +pub use tokens::{ControlCode, Digit, Letter, VoiceCode}; diff --git a/src/tokenizer/tokens.rs b/src/tokenizer/tokens.rs index fa1d7e7..d013497 100644 --- a/src/tokenizer/tokens.rs +++ b/src/tokenizer/tokens.rs @@ -1,74 +1,164 @@ -#[derive(Debug, PartialEq, Eq)] +//! The voice-code alphabet. +//! +//! `mMsg_sound_voice_get` folds the game's 256-entry character set down to these 34 +//! values. Everything after this point — word boundaries, capitalisation, sentence +//! structure, and any letter outside `a-z0-9` — is gone. + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ControlCode { - Melody, // 0x80 - Space, // 0x81 - Question, // 0x82 - Bang, // 0x83 - Newline, // 0x84 - Punct, // 0x85 - Bebe, // 0x86, - Control, // -1 + /// ♪ — plays the speaker's melody and bypasses the whole engine. + Melody, // 0x80 + Space, // 0x81 + Question, // 0x82 + Bang, // 0x83 + /// Injected by `mMsg_sound_voice_get2`; never present in the raw table. + Newline, // 0x84 + /// The catch-all for punctuation and symbols. + Punct, // 0x85 + /// The typing click. Only the interpunct (char 133) maps here. + Bebe, // 0x86 + /// An empty `b` / `c` slot in a half-built window. + Empty, // 0xFF + /// `mMsg_sound_voice_get` returning `-1` for `CHAR_CONTROL_CODE` / + /// `CHAR_MESSAGE_TAG`. Never reaches the audio engine: `mMsg_sound_CodeVoice` + /// drops it, and the `c` lookahead folds it to [`ControlCode::Empty`]. + Control, // -1 +} + +impl ControlCode { + /// [`ControlCode::Control`] has no code of its own; it reports `0xFF` because that is + /// what the lookahead substitutes, and it can never reach playback by any other path. + pub const fn code(self) -> u8 { + match self { + ControlCode::Melody => 0x80, + ControlCode::Space => 0x81, + ControlCode::Question => 0x82, + ControlCode::Bang => 0x83, + ControlCode::Newline => 0x84, + ControlCode::Punct => 0x85, + ControlCode::Bebe => 0x86, + ControlCode::Empty | ControlCode::Control => 0xFF, + } + } } -#[derive(Debug, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Letter { - A, // 0x5D - B, // 0x5E - C, // 0x5F - D, // 0x60 - E, // 0x61 - F, // 0x62 - G, // 0x63 - H, // 0x64 - I, // 0x65 - J, // 0x66 - K, // 0x67 - L, // 0x68 - M, // 0x69 - N, // 0x6A - O, // 0x6B - P, // 0x6C - Q, // 0x6D - R, // 0x6E - S, // 0x6F - T, // 0x70 - U, // 0x71 - V, // 0x72 - W, // 0x73 - X, // 0x74 - Y, // 0x75 - Z, // 0x76 + A, + B, + C, + D, + E, + F, + G, + H, + I, + J, + K, + L, + M, + N, + O, + P, + Q, + R, + S, + T, + U, + V, + W, + X, + Y, + Z, } -#[derive(Debug, PartialEq, Eq)] +impl Letter { + /// `0x5D + (letter - 'a')`. + pub const fn code(self) -> u8 { + use Letter::*; + match self { + A => 0x5D, + B => 0x5E, + C => 0x5F, + D => 0x60, + E => 0x61, + F => 0x62, + G => 0x63, + H => 0x64, + I => 0x65, + J => 0x66, + K => 0x67, + L => 0x68, + M => 0x69, + N => 0x6A, + O => 0x6B, + P => 0x6C, + Q => 0x6D, + R => 0x6E, + S => 0x6F, + T => 0x70, + U => 0x71, + V => 0x72, + W => 0x73, + X => 0x74, + Y => 0x75, + Z => 0x76, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Digit { - D0, // 0x53 - D1, // 0x54 - D2, // 0x55 - D3, // 0x56 - D4, // 0x57 - D5, // 0x58 - D6, // 0x59 - D7, // 0x5A - D8, // 0x5B - D9, // 0x5C + D0, + D1, + D2, + D3, + D4, + D5, + D6, + D7, + D8, + D9, +} + +impl Digit { + /// `0x53 + digit`. This is the *voice* code, not the phoneme code — see + /// `phonemes::digit_code` for what the conversion tables turn it into. + pub const fn code(self) -> u8 { + use Digit::*; + match self { + D0 => 0x53, + D1 => 0x54, + D2 => 0x55, + D3 => 0x56, + D4 => 0x57, + D5 => 0x58, + D6 => 0x59, + D7 => 0x5A, + D8 => 0x5B, + D9 => 0x5C, + } + } } -#[derive(Debug)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum VoiceCode { Letter(Letter), ControlCode(ControlCode), - Digit(Digit) + Digit(Digit), } - -impl Letter { - fn is_shiin(&self) -> bool { - !self.is_boin() +impl VoiceCode { + pub const fn code(self) -> u8 { + match self { + VoiceCode::Letter(l) => l.code(), + VoiceCode::ControlCode(c) => c.code(), + VoiceCode::Digit(d) => d.code(), + } } - fn is_boin(&self) -> bool { - use Letter::*; - [A, E, I, O, U, N].contains(&self) + /// `> 0x77` — the test several rules use to mean "punctuation, not a letter". + pub const fn is_punctuation(self) -> bool { + self.code() > 0x77 } } -- cgit v1.3.1