diff --git a/docs/research/articulatory-vocal-tract-sources.md b/docs/research/articulatory-vocal-tract-sources.md index b45fdc8..32a0f93 100644 --- a/docs/research/articulatory-vocal-tract-sources.md +++ b/docs/research/articulatory-vocal-tract-sources.md @@ -125,25 +125,33 @@ changes the level by -1.3 to +1.8 dB by vowel, so a single vowel is a noisy meas Plosive: word-initial /b/ closure is the tract wall's voice bar, -29.2 dB re the following vowel, /p/ -40.1 dB (`PlosiveClosureCarriesTheVoiceBarOnlyWhenVoiced`); /bɪn/ closure peak 0.0036 (was 0.0000, below 16-bit resolution), release 60-95 ms peak 0.0695, vowel 0.129. The wall radiation reads the first-cell pressure of the ladder (R0 + L0), band-limits it to 90-300 Hz and adds it at gain 0.7 (a DESIGN value for a voice-bar level of -30 dB; the primary level was not retrieved, and the 2.4 manual's skin radiation, Birkholz & Zhang 2025, is the reference to read). In a vowel the term moves the f0 line by under 1 dB; the 200 and 300 Hz harmonics move up to +1.5 dB on one open vowel (wall-only measurement, Soul pass). The band is two second-order sections, not a brick wall: with the mouth and the velum shut the bar sits about 25 dB down above 300 Hz. The burst -itself (difference of renders with and without `release_flow`) peaks at about 0.006-0.008, -24 dB re the vowel peak; the release peak in the table above is the voicing breaking through as the contact opens. Burst gain 0.06 is a DESIGN VALUE. +itself (difference of renders with and without `release_flow`), before the #47 fix (c0e6b98; see the re-measure below for the post-fix figures), peaked at about 0.006-0.008, -24 dB re the vowel peak; the release peak in the table above is the voicing breaking through as the contact opens. Burst gain 0.06 is a DESIGN VALUE. Weak spots found in the renders, 2026-11-08 (not re-measured on 2026-11-12 unless named in the next paragraph): (1) the vowel level is not steady, it rises for about 100 ms after onset (ɑ: rms 0.028 at 80 ms to 0.079 at 160 ms) so any window early in a vowel reads low; the cause is not isolated (surface smoothing and the pressure ramp are the suspects). (3) The voice bar's level is a design value and its band (90-300 Hz) assumes f0 of 90 Hz and up. (4) Only f0 and level of the first vowel are checked per cue; the cues' perceptual strength is unknown. Everything here is a measurement, nothing was heard. -Re-measure 2026-11-12 on main c0e6b98 (`tools/formant-check/weak_spots.py` over renders from `tools/aqua-listen/kits/tract-weak-spots/render.sh`; the renders reproduce the table above: /ɪ/ 375/2024 Hz, /ʊ/ 486/894 Hz, murmurs -11.81 and -9.64 dB, /i/ in /nibu/ +0.21 dB). Unheard. +Re-measure 2026-11-12. The stress, nasal and lax-vowel figures were taken on main c0e6b98, before the #47 burst fix (which does not touch them); the plosive-release bullet gives the c0e6b98 figures as "before" and the main af0e44f figures, after the #47 fix, as "after" (`tools/formant-check/weak_spots.py` over renders from `tools/aqua-listen/kits/tract-weak-spots/render.sh`; the renders reproduce the table above: /ɪ/ 375/2024 Hz, /ʊ/ 486/894 Hz, murmurs -11.81 and -9.64 dB, /i/ in /nibu/ +0.21 dB). Unheard. - Stress level (100-170 ms of each vowel, stressed against unstressed, f0 rise split from the rest by a render of the unstressed vowel at 148.4 Hz): total i +1.22, ɑ +1.67, u +2.07, e +2.99, o +1.69 dB (mean 1.93, sd 0.60); the f0 rise alone -0.80, +0.03, +0.17, +1.23, -0.07 dB; the rest (the amplitude factor 1.30 = 2.28 dB nominal, with the longer vowel) +2.02, +1.64, +1.90, +1.76, +1.76 dB (sd 0.14). The scatter between vowels is the f0 rise; the amplitude cue itself is even. No level the f0 rise cannot move was found cheap, so the five-vowel-mean basis stands (`aqua-body:question:stress-amplitude-calibration-basis`); the rest sits 0.46 dB under the nominal 2.28 dB, cause not isolated. - Plosive release: a defect was found and fixed. `release_flow` was held at the burst gain from the plosive's gesture on (a surface holds its first value before its first point and its last after its last), so every later contact state made noise: the difference of the renders with and without `release_flow` peaked at 0.0302 in the final /n/ and tail of /bɪn/ (above its 0.0226 at the release, 81 ms), 0.0126 in /bʊn/; in /nibu/ (flat) the leading /n/ carried it backward: peak 0.0688, rms 0.0067 against the /n/'s own 0.0058. - After the fix (`BurstGainAroundGesture`: zero before and after the plosive, the gain through it) the same difference is 0.0 from 120 ms after the release and the burst itself is unchanged (0.0226 at 81 ms in /bɪn/). Burst alone, after the fix: /bɪn/ 0.0226 (-15.1 dB re its vowel peak 0.129), /ba/ 0.0142 (-24.0 dB re 0.225), /bʊn/ 0.0130 (-24.3 dB re 0.213), - each falling to a tenth in 15-17 ms; the earlier "0.006-0.008, -24 dB" figure above does not hold for /bɪn/. The release peak of the full render (voicing breaking through) is -5.4, -6.3, -5.9 dB re the vowel peak. Test `PlosiveBurstExistsOnlyAroundItsOwnRelease`. + After the fix (`BurstGainAroundGesture`: zero before and after the plosive, the gain through it; figures from here to the end of the bullet are post-fix, main af0e44f) the same difference is 0.0 from 120 ms after the release and the burst itself is unchanged (0.0226 at 81 ms in /bɪn/). Burst alone, after the fix: /bɪn/ 0.0226 (-15.1 dB re its vowel peak 0.129), /ba/ 0.0142 (-24.0 dB re 0.225), /bʊn/ 0.0130 (-24.3 dB re 0.213), + each falling to a tenth in 15-17 ms; the pre-fix "0.006-0.008, -24 dB" figure above does not hold for /bɪn/. The release peak of the full render (voicing breaking through) is -5.4, -6.3, -5.9 dB re the vowel peak. Test `PlosiveBurstExistsOnlyAroundItsOwnRelease`. - Nasals: /m/ -11.81 dB, /n/ -9.64 dB re the flanking vowels, Burg N1 295 and 332 Hz. The 2 dB is not harmonic sampling: /n/ minus /m/ is 2.01, 2.17, 2.05, 1.61 dB at f0 110, 140, 170, 200 Hz. Band levels of the murmur (0-400, 400-800, 800-1600, 1600-3200 Hz): /m/ 36.3, 23.6, 21.3, 2.7; /n/ 38.4, 28.2, 16.8, 0.9 dB. The cause (the shorter oral side branch, 7.1 cm against 9.6 cm from the velum junction) is a candidate, not shown. Open. - Lax vowels: /ɪ/ and /ʊ/ are in the DSL table (`StoryVowelAreas`) and render in `WordRender` (/bɪn/, /bʊn/, isolated). Burg F1/F2: /ɪ/ 375/2024 against the tube chain 346/1987 (+8.5/+1.8 %), Hillenbrand 429/2034 (-12.5/-0.5 %), Peterson & Barney 392/1993 (-4.3/+1.5 %); /ʊ/ 486/894 against 478/850 (+1.7/+5.2 %), 469/1123 (+3.7/-20.4 %), 437/1023 (+11.3/-12.6 %). No defect found. - Phones the tract cannot make: `WordRender` refuses /t/, /d/, /k/, /l/ with exit 4 (`UNSUPPORTED PHONES (the tract has no gesture for them; nothing rendered): t`); the table holds p b m n s z ʃ ʒ ʍ w and the Story vowels. A direct `phoneme_gesture` on an unknown IPA symbol still falls back to an alveolar approximant (`GestureDescriptor`); not changed here. +Burst-window mutants (hand-applied in a scratch worktree on af0e44f plus the cut, never committed, 2026-11-12; `BurstGainAroundGesture`). The rendered test `PlosiveBurstExistsOnlyAroundItsOwnRelease` did not kill any of them, so `PlosiveBurstGainWindowIsOneRampEitherSide` reads the window off the spline (opens one ramp, 0.15 of the gesture, before the plosive; closed one ramp after it): + +| Mutant | Rendered test | Window test | +|---|---|---| +| ramp 0.15 x duration to 0 | survived | killed | +| ramp 0.15 x duration to 1.0 x duration | survived | killed | +| last point at start+duration+ramp moved to start+duration+3*ramp | survived | killed | + Mutations (hand-applied, restored, each run against the named tests): pitch ratio ignored, burst removed, burst not shaped by opening, burst gain x20 (0.06 to 1.2), stress duration ignored, stress pitch ignored, stress amplitude ignored, secondary equal to primary, probe timeline sample rate re-hardcoded to 48000: nine of nine killed. Script: `work/mut-b3.sh` in the aqua workspace. diff --git a/src/AquaSynth.Core/PatchScript.cs b/src/AquaSynth.Core/PatchScript.cs index 2fdea8d..3bac4fb 100644 --- a/src/AquaSynth.Core/PatchScript.cs +++ b/src/AquaSynth.Core/PatchScript.cs @@ -1504,7 +1504,7 @@ public static class PatchScript } } - private void EmitPhonemeGestureSplines(string name, string descriptor, float start, float duration, float intensity, float pitchRatio = 1f) + private void EmitPhonemeGestureSplines(string name, string descriptor, float start, float duration, float intensity, int line, float pitchRatio = 1f) { var tokens = DescriptorTokens(descriptor); var voiced = tokens.Contains("voiced") || tokens.Contains("vowel") || tokens.Contains("nasal") || tokens.Contains("approximant"); @@ -1527,7 +1527,7 @@ public static class PatchScript if (tokens.Contains("vowel")) { - EmitVowelGesture(name, descriptor, start, duration); + EmitVowelGesture(name, descriptor, start, duration, line); return; } @@ -1587,9 +1587,14 @@ public static class PatchScript // A vowel gesture raises the weight of the descriptor's Story vowel to 1 and holds every other vowel weight at 0 (the schwa and // any vowel descriptor without a Story shape hold all of them at 0: the geometric-mean shape). The constriction and the lip cap - // stay open, the velum closed. On a morphology without vowel_set the vowel surfaces do not exist and nothing is held. - private void EmitVowelGesture(string name, string descriptor, float start, float duration) + // stay open, the velum closed. A vowel gesture on a morphology without vowel_set is a parse error: the vowel surfaces do not exist. + private void EmitVowelGesture(string name, string descriptor, float start, float duration, int line) { + if (_areaFunctions.Count == 0 || _areaFunctions[0].Vowels is null) + { + throw new PatchScriptException(line, $"vowel gesture `{name}` needs a vowel_set before it: without one the vowel surfaces do not exist and the gesture would shape nothing"); + } + var target = StoryVowelAreas.IdForDescriptor(descriptor); foreach (var vowel in StoryVowelAreas.All) { @@ -2714,7 +2719,7 @@ public static class PatchScript : $"ipa_{SafeName(ipa)}_{_phonemeGestures.Count}"; _phonemeGestures.Add(new PhonemeGesture(name, ipa, descriptor, start, duration, intensity)); - EmitPhonemeGestureSplines(name, descriptor, start, duration, intensity, pitchRatio); + EmitPhonemeGestureSplines(name, descriptor, start, duration, intensity, line, pitchRatio); } private void AddControlCurve(IReadOnlyDictionary fields, int line) diff --git a/src/AquaSynth.Faust/FaustCompiler.cs b/src/AquaSynth.Faust/FaustCompiler.cs index 94f363b..a6efc54 100644 --- a/src/AquaSynth.Faust/FaustCompiler.cs +++ b/src/AquaSynth.Faust/FaustCompiler.cs @@ -22,7 +22,8 @@ public sealed record FaustValidation(string Command, bool Success, int? StatusCo public sealed record FaustWebAssemblyCompileOptions( string OutputDirectory, string BaseName = "aquasynth_patch", - bool Stereo = false); + bool Stereo = false, + int SampleRate = 48000); public sealed record FaustWebAssemblyManifest( string Name, @@ -89,7 +90,7 @@ public static class FaustCompiler string? faustPath = null, CancellationToken cancellationToken = default) { - var export = FaustEmitter.Emit(patch, new FaustExportOptions(options.BaseName, options.Stereo)); + var export = FaustEmitter.Emit(patch, new FaustExportOptions(options.BaseName, options.Stereo, SampleRate: options.SampleRate)); return await CompileWebAssemblySourceAsync(export.Source, options, faustPath, cancellationToken); } @@ -99,7 +100,7 @@ public static class FaustCompiler string? faustPath = null, CancellationToken cancellationToken = default) { - var export = FaustEmitter.EmitScript(script, new FaustExportOptions(options.BaseName, options.Stereo)); + var export = FaustEmitter.EmitScript(script, new FaustExportOptions(options.BaseName, options.Stereo, SampleRate: options.SampleRate)); return await CompileWebAssemblySourceAsync(export.Source, options, faustPath, cancellationToken); } diff --git a/src/AquaSynth.Faust/FaustRenderedSpeechLossSurface.cs b/src/AquaSynth.Faust/FaustRenderedSpeechLossSurface.cs index 03f9341..2c85b65 100644 --- a/src/AquaSynth.Faust/FaustRenderedSpeechLossSurface.cs +++ b/src/AquaSynth.Faust/FaustRenderedSpeechLossSurface.cs @@ -93,7 +93,7 @@ public sealed class FaustRenderedSpeechLossSurface(FaustRenderedSpeechLossOption source.AppendLine(VoiceLine("sine", F(hz), F(gain), target, 0, tract)); } - return FaustEmitter.EmitScript(source.ToString(), new FaustExportOptions("aquasynth_speech_loss")).Source; + return FaustEmitter.EmitScript(source.ToString(), new FaustExportOptions("aquasynth_speech_loss", SampleRate: options.SampleRate)).Source; } private async Task EstimateGradientAsync( diff --git a/src/AquaSynth.Faust/IpaTrialOrchestrator.cs b/src/AquaSynth.Faust/IpaTrialOrchestrator.cs index 4454451..5d4e178 100644 --- a/src/AquaSynth.Faust/IpaTrialOrchestrator.cs +++ b/src/AquaSynth.Faust/IpaTrialOrchestrator.cs @@ -309,12 +309,12 @@ public static class IpaTrialOrchestrator File.Copy(candidate.ScriptPath, scriptCopy, overwrite: true); var script = await File.ReadAllTextAsync(scriptCopy, cancellationToken).ConfigureAwait(false); var patch = PatchScript.Parse(script); - var timeline = ProbeTimelineReport.Build(patch, "voice", Math.Max(1, options.TimelineBlocks)); + var referenceFixture = PinkTromboneParityFixtures.ById(mapped.target.ReferenceFixtureId); + var reference = referenceRenderer.Render(referenceFixture.Controls, mapped.target.Target.DurationSeconds + .18f); + var timeline = ProbeTimelineReport.Build(patch, "voice", Math.Max(1, options.TimelineBlocks), reference.SampleRate); var timelinePath = Path.Combine(timelineRoot, $"{safeCandidateId}.csv"); await File.WriteAllTextAsync(timelinePath, ProbeTimelineReport.ToCsv(timeline), cancellationToken).ConfigureAwait(false); - var referenceFixture = PinkTromboneParityFixtures.ById(mapped.target.ReferenceFixtureId); - var reference = referenceRenderer.Render(referenceFixture.Controls, mapped.target.Target.DurationSeconds + .18f); var source = FaustEmitter.EmitScript(script, new FaustExportOptions(safeCandidateId, SampleRate: reference.SampleRate)).Source; var dspPath = Path.Combine(candidateDir, "candidate.dsp"); await File.WriteAllTextAsync(dspPath, source, cancellationToken).ConfigureAwait(false); diff --git a/tests/AquaSynth.Dsl.Tests/PatchScriptTests.cs b/tests/AquaSynth.Dsl.Tests/PatchScriptTests.cs index 0a0e6df..d2d1489 100644 --- a/tests/AquaSynth.Dsl.Tests/PatchScriptTests.cs +++ b/tests/AquaSynth.Dsl.Tests/PatchScriptTests.cs @@ -577,6 +577,26 @@ public sealed class PatchScriptTests timeline.SetFuturePoint("/vocal/nope", timeSeconds: .02f, normalizedValue: .5f, nowSeconds: .0f)); } + [Fact] + public void VowelGestureWithoutVowelSetFailsLoudly() + { + const string tract = """ + morphology name=oral length_cm=17 diameters=.6,.8,1.2,1.6,1.3,.9 tongue_index=3 tongue_diameter=1.4 constriction_index=4 constriction_diameter=1 lip_opening=1.5 + waveguide_path name=oral_path morphology=oral loss=.998 + source_port name=folds path=oral_path pressure=.7 tension=.55 opening=.45 noise=.05 impedance=.3 + constriction_contact name=contact path=oral_path position=.92 opening=.5 resistance=.4 stored_pressure=.1 + radiation_load name=mouth path=oral_path aperture=.8 reflection=-.82 impedance=.28 + vocal_network name=voice paths=oral_path sources=folds contacts=contact radiation=mouth + phoneme_gesture name=vowel ipa=a start=0 dur=.2 intensity=.7 + vocal network=voice freq=150 gain=.2 sustain=.3 + """; + var error = Assert.Throws(() => PatchScript.Parse(tract)); + Assert.StartsWith("line 7:", error.Message); + Assert.Contains("vowel_set", error.Message); + // With the vowel_set in front of it the same gesture parses. + PatchScript.Parse(tract.Replace("waveguide_path name=oral_path", "vowel_set area_function=oral source=story2008\nwaveguide_path name=oral_path")); + } + [Fact] public void PhonemeGestureDslEmitsAnatomicalControlSplines() { diff --git a/tests/AquaSynth.Dsl.Tests/ProbeTimelineSampleRateTests.cs b/tests/AquaSynth.Dsl.Tests/ProbeTimelineSampleRateTests.cs index 9d381d9..5756019 100644 --- a/tests/AquaSynth.Dsl.Tests/ProbeTimelineSampleRateTests.cs +++ b/tests/AquaSynth.Dsl.Tests/ProbeTimelineSampleRateTests.cs @@ -28,4 +28,14 @@ public sealed class ProbeTimelineSampleRateTests Assert.Equal(0.1752f / 325 * 48000, Delay(2, 48000), 3); Assert.Equal(0.1752f / 550 * 44100, Delay(2, 44100), 3); } + + [Fact] + public void EmittedSourceFollowsTheRequestedSampleRate() + { + // The vocal ladder's delay-line lengths are length / speed * rate samples, so the emitted DSP carries the rate it was asked for. + var at48 = FaustEmitter.EmitScript(TimelineScript, new FaustExportOptions("rate_probe", SampleRate: 48000)).Source; + var at44 = FaustEmitter.EmitScript(TimelineScript, new FaustExportOptions("rate_probe", SampleRate: 44100)).Source; + Assert.NotEqual(at48, at44); + Assert.Equal(at48, FaustEmitter.EmitScript(TimelineScript, new FaustExportOptions("rate_probe")).Source); + } } diff --git a/tests/AquaSynth.Dsl.Tests/StressAndReleaseRenderTests.cs b/tests/AquaSynth.Dsl.Tests/StressAndReleaseRenderTests.cs index 1ea7b08..b25670a 100644 --- a/tests/AquaSynth.Dsl.Tests/StressAndReleaseRenderTests.cs +++ b/tests/AquaSynth.Dsl.Tests/StressAndReleaseRenderTests.cs @@ -11,19 +11,13 @@ public sealed class StressAndReleaseRenderTests(ITestOutputHelper output) { private const int Rate = 48000; - private static float[]? Render(string script, float seconds, Func? edit = null) + private static float[] Render(string script, float seconds, Func? edit = null) { var parsed = PatchScript.Parse(script); var source = FaustEmitter.Emit(edit is null ? parsed : edit(parsed), new FaustExportOptions("stress_release", SampleRate: Rate)).Source; using var compiler = new AquaSynthPatchCompiler(new AquaSynthNativeOptions(SampleRate: Rate)); if (!compiler.TryCompileSource(new AquaSynthCompileIdentity("stress_release", "stress_release", ""), source, seconds, out var patch, out var error)) { - if (error?.Contains("Faust toolchain not found", StringComparison.OrdinalIgnoreCase) == true || - error?.Contains("Faust DLL not found", StringComparison.OrdinalIgnoreCase) == true) - { - return null; - } - Assert.Fail($"compile failed: {error}"); } @@ -57,49 +51,48 @@ public sealed class StressAndReleaseRenderTests(ITestOutputHelper output) return (double)Rate / bestLag; } - [Fact] + [FaustFact] public void SourcePitchRatioScalesTheRenderedF0() { var f0 = (float ratio) => { var script = IpaGestureExperiment.BuildWordScript([(new IpaGestureExperimentTarget("w_0_a", "a", "", 0.5f, PitchRatio: ratio), 1f)], 110); var x = Render(script, 0.6f); - return x is null ? double.NaN : F0(x, 0.2, 0.5); + return F0(x, 0.2, 0.5); }; var unity = f0(1f); var up = f0(1.5f); output.WriteLine($"f0 at ratio 1: {unity:F1}, ratio 1.5: {up:F1}"); - if (double.IsNaN(unity)) return; Assert.InRange(unity, 108, 112); Assert.InRange(up, 163, 167); } - [Fact] + [FaustFact] public void IntensityIsAnAmplitudeFactorOnTheRenderedLevel() { double Level(float intensity) { var script = IpaGestureExperiment.BuildWordScript([(new IpaGestureExperimentTarget("w_0_a", "a", "", 0.5f), intensity)], 110); var x = Render(script, 0.6f); - return x is null ? double.NaN : Rms(x, 0.3, 0.5); + return Rms(x, 0.3, 0.5); } var unity = Level(1f); - var stressed = Level(1.26f); - if (double.IsNaN(unity)) return; + var amplitude = WeksaWordPlan.CueFor(WeksaStress.Primary).Amplitude; + var stressed = Level(amplitude); var db = 20 * Math.Log10(stressed / unity); - output.WriteLine($"level at intensity 1.26 against 1: {db:F2} dB (factor wanted {20 * Math.Log10(1.26):F2})"); - Assert.InRange(db, 1.7, 2.3); + output.WriteLine($"level at the primary-stress amplitude against 1: {db:F2} dB (factor wanted {20 * Math.Log10(amplitude):F2})"); + var wanted = 20 * Math.Log10(amplitude); + Assert.InRange(db, wanted - 0.3, wanted + 0.3); } - [Fact] + [FaustFact] public void PlosiveReleaseBurstIsAboveTheClosureAndBelowTheFollowingVowel() { var script = IpaGestureExperiment.BuildWordScript( [(new IpaGestureExperimentTarget("w_0_b", "b", "", 0.095f), 1f), (new IpaGestureExperimentTarget("w_1_a", "a", "", 0.18f), 1f)], 140); var withBurst = Render(script, 0.4f); var withoutBurst = Render(script, 0.4f, p => p with { ControlSplines = p.ControlSplines.Where(s => !s.SurfacePath.EndsWith("/release_flow", StringComparison.Ordinal)).ToArray() }); - if (withBurst is null || withoutBurst is null) return; // Everything but the burst's noise is deterministic and shared, so the difference of the two renders is the burst alone. var burst = withBurst.Zip(withoutBurst, (x, y) => x - y).ToArray(); const double releaseFrom = 0.095 * 0.65, vowelFrom = 0.095; @@ -118,7 +111,7 @@ public sealed class StressAndReleaseRenderTests(ITestOutputHelper output) // gain from the plosive's gesture on, so it never ended: every later contact state made noise too. In /bin/ the "burst" alone (the // difference of the renders with and without release_flow) peaked at 0.0302 in the final /n/ and the tail, above its 0.0226 at the // release itself, and in /nibu/ the leading /n/ and /i/ carried it backward (a spline holds its first value before its first point). - [Fact] + [FaustFact] public void PlosiveBurstExistsOnlyAroundItsOwnRelease() { IpaGestureExperimentTarget Phone(string label, string ipa, float seconds) => new($"w_{label}", ipa, "", seconds); @@ -126,7 +119,6 @@ public sealed class StressAndReleaseRenderTests(ITestOutputHelper output) [(Phone("0_n", "n", 0.095f), 1f), (Phone("1_a", "a", 0.18f), 1f), (Phone("2_b", "b", 0.095f), 1f), (Phone("3_a", "a", 0.18f), 1f), (Phone("4_n", "n", 0.095f), 1f)], 140); var withBurst = Render(script, 0.77f); var withoutBurst = Render(script, 0.77f, p => p with { ControlSplines = p.ControlSplines.Where(s => !s.SurfacePath.EndsWith("/release_flow", StringComparison.Ordinal)).ToArray() }); - if (withBurst is null || withoutBurst is null) return; var burst = withBurst.Zip(withoutBurst, (x, y) => x - y).ToArray(); const double bFrom = 0.275, bTo = 0.37; var atRelease = Peak(burst, bFrom + 0.095 * 0.6, bTo + 0.02); @@ -138,6 +130,19 @@ public sealed class StressAndReleaseRenderTests(ITestOutputHelper output) Assert.True(trailing < atRelease / 10, "no burst noise in the vowel, the nasal and the tail after the plosive"); } + // The burst gain's window, read off the spline itself: it opens one ramp (0.15 of the gesture) before the plosive and has closed one ramp after it. + // The rendered test above survived three mutants of the window (ramp 0, ramp 1.0 of the gesture, end moved to 3 ramps), so the bound is pinned here. + [Fact] + public void PlosiveBurstGainWindowIsOneRampEitherSide() + { + IpaGestureExperimentTarget Phone(string label, string ipa, float seconds) => new($"w_{label}", ipa, "", seconds); + var script = IpaGestureExperiment.BuildWordScript( + [(Phone("0_n", "n", 0.095f), 1f), (Phone("1_a", "a", 0.18f), 1f), (Phone("2_b", "b", 0.095f), 1f), (Phone("3_a", "a", 0.18f), 1f), (Phone("4_n", "n", 0.095f), 1f)], 140); + var window = PatchScript.Parse(script).ControlSplines.Single(s => s.SurfacePath.EndsWith("/release_flow", StringComparison.Ordinal) && s.Points.Any(p => p.Value > 0)); + const double start = 0.275, duration = 0.095, ramp = 0.15 * duration; + Assert.Equal((start - ramp, start + duration + ramp), (window.Points.Min(p => (double)p.TimeSeconds), window.Points.Max(p => (double)p.TimeSeconds)), new TupleTolerance(1e-4)); + } + // /ɑ C ɑ/ at the scaffold's own durations (vowel 0.18 s, consonant 0.095 s). Returns the render and the consonant's [start, end). private (float[]? Samples, double From, double To) VowelConsonantVowel(string ipa, float frequency = 140) { @@ -162,13 +167,12 @@ public sealed class StressAndReleaseRenderTests(ITestOutputHelper output) // read -3.8 dB while its alveolar closure began to open at half the gesture, a third before the velum and the contact did (now held to // 0.8: /n/ -8.8, /m/ -12.8), and once the nasal junction sat on its own interface the nose took its share and both read -2.2 and -4.2 dB // until the nasal path carried its real damping (loss .975 per cell; /m/ -11.8, /n/ -9.6). - [Theory] + [FaustTheory] [InlineData("m")] [InlineData("n")] public void NasalMurmurIsWellBelowTheFlankingVowels(string ipa) { var (x, from, to) = VowelConsonantVowel(ipa); - if (x is null) return; var db = ConsonantDbReVowels(x, from, to); output.WriteLine($"/{ipa}/ murmur {db:F1} dB re the flanking vowels"); Assert.InRange(db, -16, -8); @@ -177,7 +181,7 @@ public sealed class StressAndReleaseRenderTests(ITestOutputHelper output) // The primary-stress cue as the plan applies it (amplitude and f0 ratio, same vowel length) against the unstressed vowel, over five vowels: // the f0 rise alone moves the harmonics against F1 and the level by -1.3 to +1.8 dB by vowel, so the amplitude factor is held to the mean // rendered result, 2 dB (Sluijter and van Heuven 1996: 1 to 3 dB overall). Measured 1.7 dB with the amplitude at 1.26 (outside the band), 2.0 dB at 1.30. - [Fact] + [FaustFact] public void PrimaryStressRendersAboutTwoDecibelsLouderOnAverageOverVowels() { var cue = WeksaWordPlan.CueFor(WeksaStress.Primary); @@ -188,11 +192,10 @@ public sealed class StressAndReleaseRenderTests(ITestOutputHelper output) { var script = IpaGestureExperiment.BuildWordScript([(new IpaGestureExperimentTarget($"w_0_{vowel}", vowel, "", 0.25f, PitchRatio: ratio), intensity)], 140, 0.2f); var x = Render(script, 0.45f); - return x is null ? double.NaN : Rms(x, 0.12, 0.22); + return Rms(x, 0.12, 0.22); } var flat = Level(1f, 1f); - if (double.IsNaN(flat)) return; var db = 20 * Math.Log10(Level(cue.PitchRatio, cue.Amplitude) / flat); output.WriteLine($"/{vowel}/ stressed against unstressed: {db:F2} dB"); diffs.Add(db); @@ -214,7 +217,7 @@ public sealed class StressAndReleaseRenderTests(ITestOutputHelper output) // mouth is shut, so all that gets out is the tract wall radiation of the voiced source: the voice bar, about 30 dB under the vowel for /b/ // (a DESIGN value, no primary level retrieved; Stevens 1998 per secondary sources has voicing weaker as transglottal pressure falls), and // near-silent for /p/, whose source is weak. The closure was 0.0000 (below 16 bits) before the wall radiation. - [Theory] + [FaustTheory] [InlineData("b", -34, -26)] [InlineData("p", -60, -38)] public void PlosiveClosureCarriesTheVoiceBarOnlyWhenVoiced(string ipa, double lowDb, double highDb) @@ -222,9 +225,42 @@ public sealed class StressAndReleaseRenderTests(ITestOutputHelper output) var script = IpaGestureExperiment.BuildWordScript( [(new IpaGestureExperimentTarget("w_0_c", ipa, "", 0.095f), 1f), (new IpaGestureExperimentTarget("w_1_a", "a", "", 0.18f), 1f)], 140); var x = Render(script, 0.4f); - if (x is null) return; var db = 20 * Math.Log10(Math.Max(1e-9, Deviation(x, 0.03, 0.058)) / Deviation(x, 0.15, 0.25)); output.WriteLine($"/{ipa}/ closure {db:F1} dB re the following vowel"); Assert.InRange(db, lowDb, highDb); } } + +// Reports Skipped, not Passed, when the Faust toolchain is absent: a test that returns early reads as a pass nobody earned. +public sealed class FaustFactAttribute : FactAttribute +{ + public FaustFactAttribute() + { + if (AquaSynthFaustToolchain.Find() is null) Skip = "Faust toolchain not found"; + } +} + +public sealed class FaustTheoryAttribute : TheoryAttribute +{ + public FaustTheoryAttribute() + { + if (AquaSynthFaustToolchain.Find() is null) Skip = "Faust toolchain not found"; + } +} + +public sealed class FaustSkipTests +{ + [Fact] + public void StressTestsSkipWithoutFaust() + { + var toolchain = AquaSynthFaustToolchain.Find(); + Assert.Equal(toolchain is null, new FaustFactAttribute().Skip is not null); + Assert.Equal(toolchain is null, new FaustTheoryAttribute().Skip is not null); + } +} + +file sealed class TupleTolerance(double eps) : IEqualityComparer<(double, double)> +{ + public bool Equals((double, double) x, (double, double) y) => Math.Abs(x.Item1 - y.Item1) < eps && Math.Abs(x.Item2 - y.Item2) < eps; + public int GetHashCode((double, double) value) => 0; +} diff --git a/tools/AquaLower/Program.cs b/tools/AquaLower/Program.cs index 103b913..018a52c 100644 --- a/tools/AquaLower/Program.cs +++ b/tools/AquaLower/Program.cs @@ -16,6 +16,7 @@ if (args.Length == 2 && args[0] == "--dings") var instruments = new List(); foreach (var ins in DingCatalog.Instruments.Values.OrderBy(i => i.Id, StringComparer.Ordinal)) { + // 48000 (the FaustExportOptions default) is the Ding catalog contract: DingCatalogTests pins the sources at that rate. var export = FaustEmitter.EmitScript(ins.Script, new FaustExportOptions(DingCatalog.Identity(ins).FaustName)); File.WriteAllText(Path.Combine(dingDir, ins.Id + ".dsp"), export.Source); instruments.Add(new { id = ins.Id, rootFrequency = ins.RootFrequency, dsp = ins.Id + ".dsp", controlPath = "/ding/frequency", warnings = export.Warnings }); @@ -42,6 +43,7 @@ foreach (var spec in args.Skip(2)) var warnings = new List(); if (path.EndsWith(".aqua", StringComparison.Ordinal)) { + // 48000 (the FaustExportOptions default): AquaLower lowers for the 48 kHz hosts. var export = FaustEmitter.EmitScript(text, new FaustExportOptions(name)); source = export.Source; warnings.AddRange(export.Warnings); diff --git a/tools/IpaTrialWorker/Program.cs b/tools/IpaTrialWorker/Program.cs index 1e7cbbc..661a96d 100644 --- a/tools/IpaTrialWorker/Program.cs +++ b/tools/IpaTrialWorker/Program.cs @@ -264,7 +264,7 @@ static async Task SongScoreAsync(Dictionary options) var timelineNetwork = patch.VocalNetworks.FirstOrDefault(); var timeline = timelineNetwork is null ? Array.Empty() - : ProbeTimelineReport.Build(patch, timelineNetwork.Name, 12); + : ProbeTimelineReport.Build(patch, timelineNetwork.Name, 12, challenge.SampleRate); await File.WriteAllTextAsync(timelinePath, ProbeTimelineReport.ToCsv(timeline), Encoding.UTF8); timelineFacts = PrimitiveTimelineFactExtractor.Extract(timeline).ToArray(); var source = FaustEmitter.EmitScript(script, new FaustExportOptions(safeCandidateId, SampleRate: challenge.SampleRate)).Source; diff --git a/tools/WordRender/Program.cs b/tools/WordRender/Program.cs index bfee4d6..32e01c8 100644 --- a/tools/WordRender/Program.cs +++ b/tools/WordRender/Program.cs @@ -45,7 +45,7 @@ using var compiler = new AquaSynthPatchCompiler(new AquaSynthNativeOptions(Sampl // itself and no slider is driven from outside. var parsed = PatchScript.Parse(script); if (noBurst) parsed = parsed with { ControlSplines = parsed.ControlSplines.Where(s => !s.SurfacePath.EndsWith("/release_flow", StringComparison.Ordinal)).ToArray() }; -var source = FaustEmitter.Emit(parsed, new FaustExportOptions(label)).Source; +var source = FaustEmitter.Emit(parsed, new FaustExportOptions(label, SampleRate: Rate)).Source; File.WriteAllText(Path.Combine(outDir, "source.dsp"), source); if (!compiler.TryCompileSource(new AquaSynthCompileIdentity(label, label, ""), source, seconds, out var patch0, out var cerr)) {