|
1 | 1 | import type { Disposable } from '@type/disposable'; |
2 | 2 | import type { Observer } from '@type/observable'; |
3 | | -import type { AudioState, PlotState } from '@type/state'; |
| 3 | +import type { AudioState, PlotState, TouchGuidanceState } from '@type/state'; |
4 | 4 | import type { AudioPaletteEntry } from './audioPalette'; |
5 | 5 | import type { NotificationService } from './notification'; |
6 | 6 | import type { SettingsService } from './settings'; |
@@ -40,6 +40,15 @@ const WARNING_FREQUENCY = 180; |
40 | 40 | const WARNING_DURATION = 0.2; |
41 | 41 | const WARNING_SPACE = 0.1; |
42 | 42 |
|
| 43 | +const TOUCH_GUIDANCE_MAX_DISTANCE_PX = 160; |
| 44 | +const TOUCH_GUIDANCE_MIN_INTERVAL = 0.08; |
| 45 | +const TOUCH_GUIDANCE_MAX_INTERVAL = 0.55; |
| 46 | +const TOUCH_GUIDANCE_HIGH_FREQUENCY = 1280; |
| 47 | +const TOUCH_GUIDANCE_LOW_FREQUENCY = 420; |
| 48 | +const TOUCH_GUIDANCE_BEEP_DURATION = 0.06; |
| 49 | +const TOUCH_GUIDANCE_PAN_MAGNITUDE = 0.7; |
| 50 | +const TOUCH_GUIDANCE_VOLUME = 0.35; |
| 51 | + |
43 | 52 | const DEFAULT_DURATION = 0.3; |
44 | 53 | const DEFAULT_PALETTE_INDEX = AudioPaletteIndex.SINE_BASIC; |
45 | 54 |
|
@@ -81,6 +90,7 @@ export class AudioService implements Observer<PlotState>, Disposable { |
81 | 90 | private maxFrequency: number; |
82 | 91 | private readonly audioContext: AudioContext; |
83 | 92 | private readonly compressor: DynamicsCompressorNode; |
| 93 | + private nextTouchGuidanceBeepAt: number; |
84 | 94 |
|
85 | 95 | /** |
86 | 96 | * Creates an instance of AudioService. |
@@ -117,6 +127,7 @@ export class AudioService implements Observer<PlotState>, Disposable { |
117 | 127 |
|
118 | 128 | this.audioContext = new AudioContext(); |
119 | 129 | this.compressor = this.initCompressor(); |
| 130 | + this.nextTouchGuidanceBeepAt = 0; |
120 | 131 | } |
121 | 132 |
|
122 | 133 | /** |
@@ -627,6 +638,88 @@ export class AudioService implements Observer<PlotState>, Disposable { |
627 | 638 | this.playWarningTone(); |
628 | 639 | } |
629 | 640 |
|
| 641 | + /** |
| 642 | + * Plays directional touch/pointer guidance beeps for nearby curve exploration. |
| 643 | + * |
| 644 | + * Behavior: |
| 645 | + * - No guidance beep when on-curve (regular sonification continues to apply). |
| 646 | + * - Off-curve beeps repeat faster when closer and slower when farther. |
| 647 | + * - High pitch when pointer is below the curve, low pitch when above. |
| 648 | + * - Pan left when pointer is right of curve, pan right when left of curve. |
| 649 | + * |
| 650 | + * @param guidance - Touch guidance state from the active trace, or null to reset guidance |
| 651 | + */ |
| 652 | + public playTouchGuidance(guidance: TouchGuidanceState | null): void { |
| 653 | + if ( |
| 654 | + this.mode === AudioMode.OFF |
| 655 | + || !guidance |
| 656 | + || guidance.onCurve |
| 657 | + || guidance.distancePx > TOUCH_GUIDANCE_MAX_DISTANCE_PX |
| 658 | + ) { |
| 659 | + this.nextTouchGuidanceBeepAt = 0; |
| 660 | + return; |
| 661 | + } |
| 662 | + |
| 663 | + const now = this.audioContext.currentTime; |
| 664 | + if (now < this.nextTouchGuidanceBeepAt) { |
| 665 | + return; |
| 666 | + } |
| 667 | + |
| 668 | + const distanceNorm = this.clamp( |
| 669 | + guidance.distancePx / TOUCH_GUIDANCE_MAX_DISTANCE_PX, |
| 670 | + 0, |
| 671 | + 1, |
| 672 | + ); |
| 673 | + const interval = this.interpolate( |
| 674 | + distanceNorm, |
| 675 | + { min: 0, max: 1 }, |
| 676 | + { min: TOUCH_GUIDANCE_MIN_INTERVAL, max: TOUCH_GUIDANCE_MAX_INTERVAL }, |
| 677 | + ); |
| 678 | + |
| 679 | + const frequency = guidance.verticalRelation === 'below' |
| 680 | + ? TOUCH_GUIDANCE_HIGH_FREQUENCY |
| 681 | + : TOUCH_GUIDANCE_LOW_FREQUENCY; |
| 682 | + const pan = guidance.horizontalRelation === 'right' |
| 683 | + ? -TOUCH_GUIDANCE_PAN_MAGNITUDE |
| 684 | + : TOUCH_GUIDANCE_PAN_MAGNITUDE; |
| 685 | + |
| 686 | + this.playTouchGuidanceBeep(frequency, pan, now); |
| 687 | + this.nextTouchGuidanceBeepAt = now + interval; |
| 688 | + } |
| 689 | + |
| 690 | + private playTouchGuidanceBeep( |
| 691 | + frequency: number, |
| 692 | + pan: number, |
| 693 | + startTime: number, |
| 694 | + ): void { |
| 695 | + const oscillator = this.audioContext.createOscillator(); |
| 696 | + oscillator.type = 'sine'; |
| 697 | + oscillator.frequency.value = frequency; |
| 698 | + |
| 699 | + const gainNode = this.audioContext.createGain(); |
| 700 | + const guidanceVolume = Math.max(this.volume * TOUCH_GUIDANCE_VOLUME, 0.05); |
| 701 | + gainNode.gain.setValueAtTime(guidanceVolume, startTime); |
| 702 | + gainNode.gain.exponentialRampToValueAtTime(0.001, startTime + TOUCH_GUIDANCE_BEEP_DURATION); |
| 703 | + |
| 704 | + const stereoPanner = this.audioContext.createStereoPanner(); |
| 705 | + stereoPanner.pan.value = this.clamp(pan, -1, 1); |
| 706 | + |
| 707 | + oscillator.connect(gainNode); |
| 708 | + gainNode.connect(stereoPanner); |
| 709 | + stereoPanner.connect(this.compressor); |
| 710 | + |
| 711 | + oscillator.start(startTime); |
| 712 | + oscillator.stop(startTime + TOUCH_GUIDANCE_BEEP_DURATION); |
| 713 | + |
| 714 | + const audioId = setTimeout(() => { |
| 715 | + oscillator.disconnect(); |
| 716 | + gainNode.disconnect(); |
| 717 | + stereoPanner.disconnect(); |
| 718 | + this.activeAudioIds.delete(audioId); |
| 719 | + }, TOUCH_GUIDANCE_BEEP_DURATION * 1000 * 2); |
| 720 | + this.activeAudioIds.set(audioId, oscillator); |
| 721 | + } |
| 722 | + |
630 | 723 | private playZeroTone(panning: Panning): AudioId { |
631 | 724 | const xPos = this.clamp(this.interpolate(panning.x, { min: 0, max: panning.cols - 1 }, { min: -1, max: 1 }), -1, 1); |
632 | 725 | // Y-axis not used for stereo panning |
|
0 commit comments